pytest-homeassistant-custom-component 0.13.163__py3-none-any.whl → 0.13.298__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. pytest_homeassistant_custom_component/common.py +289 -46
  2. pytest_homeassistant_custom_component/components/__init__.py +351 -0
  3. pytest_homeassistant_custom_component/components/diagnostics/__init__.py +71 -0
  4. pytest_homeassistant_custom_component/components/recorder/common.py +143 -13
  5. pytest_homeassistant_custom_component/components/recorder/db_schema_0.py +1 -1
  6. pytest_homeassistant_custom_component/const.py +4 -3
  7. pytest_homeassistant_custom_component/patch_json.py +41 -0
  8. pytest_homeassistant_custom_component/patch_recorder.py +1 -1
  9. pytest_homeassistant_custom_component/patch_time.py +66 -0
  10. pytest_homeassistant_custom_component/plugins.py +429 -177
  11. pytest_homeassistant_custom_component/syrupy.py +188 -1
  12. pytest_homeassistant_custom_component/test_util/aiohttp.py +56 -20
  13. pytest_homeassistant_custom_component/typing.py +5 -0
  14. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/METADATA +54 -29
  15. pytest_homeassistant_custom_component-0.13.298.dist-info/RECORD +28 -0
  16. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/WHEEL +1 -1
  17. pytest_homeassistant_custom_component-0.13.163.dist-info/RECORD +0 -26
  18. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/entry_points.txt +0 -0
  19. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info/licenses}/LICENSE +0 -0
  20. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info/licenses}/LICENSE_HA_CORE.md +0 -0
  21. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/top_level.txt +0 -0
@@ -3,3 +3,354 @@ The tests for components.
3
3
 
4
4
  This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component.
5
5
  """
6
+
7
+ from enum import StrEnum
8
+ import itertools
9
+ from typing import TypedDict
10
+
11
+ from homeassistant.const import (
12
+ ATTR_AREA_ID,
13
+ ATTR_DEVICE_ID,
14
+ ATTR_FLOOR_ID,
15
+ ATTR_LABEL_ID,
16
+ CONF_ENTITY_ID,
17
+ CONF_OPTIONS,
18
+ CONF_PLATFORM,
19
+ CONF_TARGET,
20
+ STATE_UNAVAILABLE,
21
+ STATE_UNKNOWN,
22
+ )
23
+ from homeassistant.core import HomeAssistant
24
+ from homeassistant.helpers import (
25
+ area_registry as ar,
26
+ device_registry as dr,
27
+ entity_registry as er,
28
+ floor_registry as fr,
29
+ label_registry as lr,
30
+ )
31
+ from homeassistant.setup import async_setup_component
32
+
33
+ from ..common import MockConfigEntry, mock_device_registry
34
+
35
+
36
+ async def target_entities(
37
+ hass: HomeAssistant, domain: str
38
+ ) -> tuple[list[str], list[str]]:
39
+ """Create multiple entities associated with different targets.
40
+
41
+ Returns a dict with the following keys:
42
+ - included: List of entity_ids meant to be targeted.
43
+ - excluded: List of entity_ids not meant to be targeted.
44
+ """
45
+ await async_setup_component(hass, domain, {})
46
+
47
+ config_entry = MockConfigEntry(domain="test")
48
+ config_entry.add_to_hass(hass)
49
+
50
+ floor_reg = fr.async_get(hass)
51
+ floor = floor_reg.async_get_floor_by_name("Test Floor") or floor_reg.async_create(
52
+ "Test Floor"
53
+ )
54
+
55
+ area_reg = ar.async_get(hass)
56
+ area = area_reg.async_get_area_by_name("Test Area") or area_reg.async_create(
57
+ "Test Area", floor_id=floor.floor_id
58
+ )
59
+
60
+ label_reg = lr.async_get(hass)
61
+ label = label_reg.async_get_label_by_name("Test Label") or label_reg.async_create(
62
+ "Test Label"
63
+ )
64
+
65
+ device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
66
+ mock_device_registry(hass, {device.id: device})
67
+
68
+ entity_reg = er.async_get(hass)
69
+ # Entities associated with area
70
+ entity_area = entity_reg.async_get_or_create(
71
+ domain=domain,
72
+ platform="test",
73
+ unique_id=f"{domain}_area",
74
+ suggested_object_id=f"area_{domain}",
75
+ )
76
+ entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id)
77
+ entity_area_excluded = entity_reg.async_get_or_create(
78
+ domain=domain,
79
+ platform="test",
80
+ unique_id=f"{domain}_area_excluded",
81
+ suggested_object_id=f"area_{domain}_excluded",
82
+ )
83
+ entity_reg.async_update_entity(entity_area_excluded.entity_id, area_id=area.id)
84
+
85
+ # Entities associated with device
86
+ entity_reg.async_get_or_create(
87
+ domain=domain,
88
+ platform="test",
89
+ unique_id=f"{domain}_device",
90
+ suggested_object_id=f"device_{domain}",
91
+ device_id=device.id,
92
+ )
93
+ entity_reg.async_get_or_create(
94
+ domain=domain,
95
+ platform="test",
96
+ unique_id=f"{domain}_device_excluded",
97
+ suggested_object_id=f"device_{domain}_excluded",
98
+ device_id=device.id,
99
+ )
100
+
101
+ # Entities associated with label
102
+ entity_label = entity_reg.async_get_or_create(
103
+ domain=domain,
104
+ platform="test",
105
+ unique_id=f"{domain}_label",
106
+ suggested_object_id=f"label_{domain}",
107
+ )
108
+ entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id})
109
+ entity_label_excluded = entity_reg.async_get_or_create(
110
+ domain=domain,
111
+ platform="test",
112
+ unique_id=f"{domain}_label_excluded",
113
+ suggested_object_id=f"label_{domain}_excluded",
114
+ )
115
+ entity_reg.async_update_entity(
116
+ entity_label_excluded.entity_id, labels={label.label_id}
117
+ )
118
+
119
+ # Return all available entities
120
+ return {
121
+ "included": [
122
+ f"{domain}.standalone_{domain}",
123
+ f"{domain}.label_{domain}",
124
+ f"{domain}.area_{domain}",
125
+ f"{domain}.device_{domain}",
126
+ ],
127
+ "excluded": [
128
+ f"{domain}.standalone_{domain}_excluded",
129
+ f"{domain}.label_{domain}_excluded",
130
+ f"{domain}.area_{domain}_excluded",
131
+ f"{domain}.device_{domain}_excluded",
132
+ ],
133
+ }
134
+
135
+
136
+ def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]:
137
+ """Parametrize target entities for different target types.
138
+
139
+ Meant to be used with target_entities.
140
+ """
141
+ return [
142
+ (
143
+ {CONF_ENTITY_ID: f"{domain}.standalone_{domain}"},
144
+ f"{domain}.standalone_{domain}",
145
+ 1,
146
+ ),
147
+ ({ATTR_LABEL_ID: "test_label"}, f"{domain}.label_{domain}", 2),
148
+ ({ATTR_AREA_ID: "test_area"}, f"{domain}.area_{domain}", 2),
149
+ ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.area_{domain}", 2),
150
+ ({ATTR_LABEL_ID: "test_label"}, f"{domain}.device_{domain}", 2),
151
+ ({ATTR_AREA_ID: "test_area"}, f"{domain}.device_{domain}", 2),
152
+ ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.device_{domain}", 2),
153
+ ({ATTR_DEVICE_ID: "test_device"}, f"{domain}.device_{domain}", 1),
154
+ ]
155
+
156
+
157
+ class _StateDescription(TypedDict):
158
+ """Test state and expected service call count."""
159
+
160
+ state: str | None
161
+ attributes: dict
162
+
163
+
164
+ class StateDescription(TypedDict):
165
+ """Test state and expected service call count."""
166
+
167
+ included: _StateDescription
168
+ excluded: _StateDescription
169
+ count: int
170
+
171
+
172
+ def parametrize_trigger_states(
173
+ *,
174
+ trigger: str,
175
+ target_states: list[str | None | tuple[str | None, dict]],
176
+ other_states: list[str | None | tuple[str | None, dict]],
177
+ additional_attributes: dict | None = None,
178
+ trigger_from_none: bool = True,
179
+ ) -> list[tuple[str, list[StateDescription]]]:
180
+ """Parametrize states and expected service call counts.
181
+
182
+ The target_states and other_states iterables are either iterables of
183
+ states or iterables of (state, attributes) tuples.
184
+
185
+ Set `trigger_from_none` to False if the trigger is not expected to fire
186
+ when the initial state is None.
187
+
188
+ Returns a list of tuples with (trigger, list of states),
189
+ where states is a list of StateDescription dicts.
190
+ """
191
+
192
+ additional_attributes = additional_attributes or {}
193
+
194
+ def state_with_attributes(
195
+ state: str | None | tuple[str | None, dict], count: int
196
+ ) -> dict:
197
+ """Return (state, attributes) dict."""
198
+ if isinstance(state, str) or state is None:
199
+ return {
200
+ "included": {
201
+ "state": state,
202
+ "attributes": additional_attributes,
203
+ },
204
+ "excluded": {
205
+ "state": state,
206
+ "attributes": {},
207
+ },
208
+ "count": count,
209
+ }
210
+ return {
211
+ "included": {
212
+ "state": state[0],
213
+ "attributes": state[1] | additional_attributes,
214
+ },
215
+ "excluded": {
216
+ "state": state[0],
217
+ "attributes": state[1],
218
+ },
219
+ "count": count,
220
+ }
221
+
222
+ return [
223
+ # Initial state None
224
+ (
225
+ trigger,
226
+ list(
227
+ itertools.chain.from_iterable(
228
+ (
229
+ state_with_attributes(None, 0),
230
+ state_with_attributes(target_state, 0),
231
+ state_with_attributes(other_state, 0),
232
+ state_with_attributes(
233
+ target_state, 1 if trigger_from_none else 0
234
+ ),
235
+ )
236
+ for target_state in target_states
237
+ for other_state in other_states
238
+ )
239
+ ),
240
+ ),
241
+ # Initial state different from target state
242
+ (
243
+ trigger,
244
+ # other_state,
245
+ list(
246
+ itertools.chain.from_iterable(
247
+ (
248
+ state_with_attributes(other_state, 0),
249
+ state_with_attributes(target_state, 1),
250
+ state_with_attributes(other_state, 0),
251
+ state_with_attributes(target_state, 1),
252
+ )
253
+ for target_state in target_states
254
+ for other_state in other_states
255
+ )
256
+ ),
257
+ ),
258
+ # Initial state same as target state
259
+ (
260
+ trigger,
261
+ list(
262
+ itertools.chain.from_iterable(
263
+ (
264
+ state_with_attributes(target_state, 0),
265
+ state_with_attributes(target_state, 0),
266
+ state_with_attributes(other_state, 0),
267
+ state_with_attributes(target_state, 1),
268
+ )
269
+ for target_state in target_states
270
+ for other_state in other_states
271
+ )
272
+ ),
273
+ ),
274
+ # Initial state unavailable / unknown
275
+ (
276
+ trigger,
277
+ list(
278
+ itertools.chain.from_iterable(
279
+ (
280
+ state_with_attributes(STATE_UNAVAILABLE, 0),
281
+ state_with_attributes(target_state, 0),
282
+ state_with_attributes(other_state, 0),
283
+ state_with_attributes(target_state, 1),
284
+ )
285
+ for target_state in target_states
286
+ for other_state in other_states
287
+ )
288
+ ),
289
+ ),
290
+ (
291
+ trigger,
292
+ list(
293
+ itertools.chain.from_iterable(
294
+ (
295
+ state_with_attributes(STATE_UNKNOWN, 0),
296
+ state_with_attributes(target_state, 0),
297
+ state_with_attributes(other_state, 0),
298
+ state_with_attributes(target_state, 1),
299
+ )
300
+ for target_state in target_states
301
+ for other_state in other_states
302
+ )
303
+ ),
304
+ ),
305
+ ]
306
+
307
+
308
+ async def arm_trigger(
309
+ hass: HomeAssistant,
310
+ trigger: str,
311
+ trigger_options: dict | None,
312
+ trigger_target: dict,
313
+ ) -> None:
314
+ """Arm the specified trigger, call service test.automation when it triggers."""
315
+
316
+ # Local include to avoid importing the automation component unnecessarily
317
+ from homeassistant.components import automation # noqa: PLC0415
318
+
319
+ options = {CONF_OPTIONS: {**trigger_options}} if trigger_options is not None else {}
320
+
321
+ await async_setup_component(
322
+ hass,
323
+ automation.DOMAIN,
324
+ {
325
+ automation.DOMAIN: {
326
+ "trigger": {
327
+ CONF_PLATFORM: trigger,
328
+ CONF_TARGET: {**trigger_target},
329
+ }
330
+ | options,
331
+ "action": {
332
+ "service": "test.automation",
333
+ "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
334
+ },
335
+ }
336
+ },
337
+ )
338
+
339
+
340
+ def set_or_remove_state(
341
+ hass: HomeAssistant,
342
+ entity_id: str,
343
+ state: StateDescription,
344
+ ) -> None:
345
+ """Set or remove the state of an entity."""
346
+ if state["state"] is None:
347
+ hass.states.async_remove(entity_id)
348
+ else:
349
+ hass.states.async_set(
350
+ entity_id, state["state"], state["attributes"], force_update=True
351
+ )
352
+
353
+
354
+ def other_states(state: StrEnum) -> list[str]:
355
+ """Return a sorted list with all states except the specified one."""
356
+ return sorted({s.value for s in state.__class__} - {state.value})
@@ -0,0 +1,71 @@
1
+ """
2
+ Tests for the Diagnostics integration.
3
+
4
+ This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component.
5
+ """
6
+
7
+ from http import HTTPStatus
8
+ from typing import cast
9
+
10
+ from homeassistant.config_entries import ConfigEntry
11
+ from homeassistant.core import HomeAssistant
12
+ from homeassistant.helpers.device_registry import DeviceEntry
13
+ from homeassistant.setup import async_setup_component
14
+ from homeassistant.util.json import JsonObjectType
15
+
16
+ from pytest_homeassistant_custom_component.typing import ClientSessionGenerator
17
+
18
+
19
+ async def _get_diagnostics_for_config_entry(
20
+ hass: HomeAssistant,
21
+ hass_client: ClientSessionGenerator,
22
+ config_entry: ConfigEntry,
23
+ ) -> JsonObjectType:
24
+ """Return the diagnostics config entry for the specified domain."""
25
+ assert await async_setup_component(hass, "diagnostics", {})
26
+ await hass.async_block_till_done()
27
+
28
+ client = await hass_client()
29
+ response = await client.get(
30
+ f"/api/diagnostics/config_entry/{config_entry.entry_id}"
31
+ )
32
+ assert response.status == HTTPStatus.OK
33
+ return cast(JsonObjectType, await response.json())
34
+
35
+
36
+ async def get_diagnostics_for_config_entry(
37
+ hass: HomeAssistant,
38
+ hass_client: ClientSessionGenerator,
39
+ config_entry: ConfigEntry,
40
+ ) -> JsonObjectType:
41
+ """Return the diagnostics config entry for the specified domain."""
42
+ data = await _get_diagnostics_for_config_entry(hass, hass_client, config_entry)
43
+ return cast(JsonObjectType, data["data"])
44
+
45
+
46
+ async def _get_diagnostics_for_device(
47
+ hass: HomeAssistant,
48
+ hass_client: ClientSessionGenerator,
49
+ config_entry: ConfigEntry,
50
+ device: DeviceEntry,
51
+ ) -> JsonObjectType:
52
+ """Return the diagnostics for the specified device."""
53
+ assert await async_setup_component(hass, "diagnostics", {})
54
+
55
+ client = await hass_client()
56
+ response = await client.get(
57
+ f"/api/diagnostics/config_entry/{config_entry.entry_id}/device/{device.id}"
58
+ )
59
+ assert response.status == HTTPStatus.OK
60
+ return cast(JsonObjectType, await response.json())
61
+
62
+
63
+ async def get_diagnostics_for_device(
64
+ hass: HomeAssistant,
65
+ hass_client: ClientSessionGenerator,
66
+ config_entry: ConfigEntry,
67
+ device: DeviceEntry,
68
+ ) -> JsonObjectType:
69
+ """Return the diagnostics for the specified device."""
70
+ data = await _get_diagnostics_for_device(hass, hass_client, config_entry, device)
71
+ return cast(JsonObjectType, data["data"])
@@ -15,11 +15,13 @@ from functools import partial
15
15
  import importlib
16
16
  import sys
17
17
  import time
18
+ from types import ModuleType
18
19
  from typing import Any, Literal, cast
19
20
  from unittest.mock import MagicMock, patch, sentinel
20
21
 
21
22
  from freezegun import freeze_time
22
- from sqlalchemy import create_engine
23
+ import pytest
24
+ from sqlalchemy import create_engine, event as sqlalchemy_event
23
25
  from sqlalchemy.orm.session import Session
24
26
 
25
27
  from homeassistant import core as ha
@@ -32,16 +34,25 @@ from homeassistant.components.recorder import (
32
34
  statistics,
33
35
  )
34
36
  from homeassistant.components.recorder.db_schema import (
37
+ EventData,
35
38
  Events,
36
39
  EventTypes,
37
40
  RecorderRuns,
41
+ StateAttributes,
38
42
  States,
39
43
  StatesMeta,
40
44
  )
45
+ from homeassistant.components.recorder.models import (
46
+ bytes_to_ulid_or_none,
47
+ bytes_to_uuid_hex_or_none,
48
+ )
41
49
  from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask
42
- from homeassistant.const import UnitOfTemperature
50
+ from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
51
+ from homeassistant.const import DEGREE, UnitOfTemperature
43
52
  from homeassistant.core import Event, HomeAssistant, State
44
- import homeassistant.util.dt as dt_util
53
+ from homeassistant.helpers import recorder as recorder_helper
54
+ from homeassistant.util import dt as dt_util
55
+ from homeassistant.util.json import json_loads, json_loads_object
45
56
 
46
57
  from . import db_schema_0
47
58
 
@@ -83,6 +94,11 @@ async def async_block_recorder(hass: HomeAssistant, seconds: float) -> None:
83
94
  await event.wait()
84
95
 
85
96
 
97
+ async def async_wait_recorder(hass: HomeAssistant) -> bool:
98
+ """Wait for recorder to initialize and return connection status."""
99
+ return await hass.data[recorder_helper.DATA_RECORDER].db_connected
100
+
101
+
86
102
  def get_start_time(start: datetime) -> datetime:
87
103
  """Calculate a valid start time for statistics."""
88
104
  start_minutes = start.minute - start.minute % 5
@@ -288,6 +304,7 @@ def record_states(
288
304
  sns2 = "sensor.test2"
289
305
  sns3 = "sensor.test3"
290
306
  sns4 = "sensor.test4"
307
+ sns5 = "sensor.wind_direction"
291
308
  sns1_attr = {
292
309
  "device_class": "temperature",
293
310
  "state_class": "measurement",
@@ -300,6 +317,11 @@ def record_states(
300
317
  }
301
318
  sns3_attr = {"device_class": "temperature"}
302
319
  sns4_attr = {}
320
+ sns5_attr = {
321
+ "device_class": SensorDeviceClass.WIND_DIRECTION,
322
+ "state_class": SensorStateClass.MEASUREMENT_ANGLE,
323
+ "unit_of_measurement": DEGREE,
324
+ }
303
325
 
304
326
  def set_state(entity_id, state, **kwargs):
305
327
  """Set the state."""
@@ -313,7 +335,7 @@ def record_states(
313
335
  three = two + timedelta(seconds=30 * 5)
314
336
  four = three + timedelta(seconds=14 * 5)
315
337
 
316
- states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []}
338
+ states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: [], sns5: []}
317
339
  with freeze_time(one) as freezer:
318
340
  states[mp].append(
319
341
  set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)})
@@ -322,6 +344,7 @@ def record_states(
322
344
  states[sns2].append(set_state(sns2, "10", attributes=sns2_attr))
323
345
  states[sns3].append(set_state(sns3, "10", attributes=sns3_attr))
324
346
  states[sns4].append(set_state(sns4, "10", attributes=sns4_attr))
347
+ states[sns5].append(set_state(sns5, "10", attributes=sns5_attr))
325
348
 
326
349
  freezer.move_to(one + timedelta(microseconds=1))
327
350
  states[mp].append(
@@ -333,12 +356,14 @@ def record_states(
333
356
  states[sns2].append(set_state(sns2, "15", attributes=sns2_attr))
334
357
  states[sns3].append(set_state(sns3, "15", attributes=sns3_attr))
335
358
  states[sns4].append(set_state(sns4, "15", attributes=sns4_attr))
359
+ states[sns5].append(set_state(sns5, "350", attributes=sns5_attr))
336
360
 
337
361
  freezer.move_to(three)
338
362
  states[sns1].append(set_state(sns1, "20", attributes=sns1_attr))
339
363
  states[sns2].append(set_state(sns2, "20", attributes=sns2_attr))
340
364
  states[sns3].append(set_state(sns3, "20", attributes=sns3_attr))
341
365
  states[sns4].append(set_state(sns4, "20", attributes=sns4_attr))
366
+ states[sns5].append(set_state(sns5, "5", attributes=sns5_attr))
342
367
 
343
368
  return zero, four, states
344
369
 
@@ -412,7 +437,15 @@ def create_engine_test_for_schema_version_postfix(
412
437
  schema_module = get_schema_module_path(schema_version_postfix)
413
438
  importlib.import_module(schema_module)
414
439
  old_db_schema = sys.modules[schema_module]
440
+ instance: Recorder | None = None
441
+ if "hass" in kwargs:
442
+ hass: HomeAssistant = kwargs.pop("hass")
443
+ instance = recorder.get_instance(hass)
415
444
  engine = create_engine(*args, **kwargs)
445
+ if instance is not None:
446
+ instance = recorder.get_instance(hass)
447
+ instance.engine = engine
448
+ sqlalchemy_event.listen(engine, "connect", instance._setup_recorder_connection)
416
449
  old_db_schema.Base.metadata.create_all(engine)
417
450
  with Session(engine) as session:
418
451
  session.add(
@@ -432,16 +465,15 @@ def get_schema_module_path(schema_version_postfix: str) -> str:
432
465
  return f"...components.recorder.db_schema_{schema_version_postfix}"
433
466
 
434
467
 
435
- @dataclass(slots=True)
436
- class MockMigrationTask(migration.MigrationTask):
437
- """Mock migration task which does nothing."""
438
-
439
- def run(self, instance: Recorder) -> None:
440
- """Run migration task."""
468
+ def get_patched_live_version(old_db_schema: ModuleType) -> int:
469
+ """Return the patched live migration version."""
470
+ return min(
471
+ migration.LIVE_MIGRATION_MIN_SCHEMA_VERSION, old_db_schema.SCHEMA_VERSION
472
+ )
441
473
 
442
474
 
443
475
  @contextmanager
444
- def old_db_schema(schema_version_postfix: str) -> Iterator[None]:
476
+ def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]:
445
477
  """Fixture to initialize the db with the old schema."""
446
478
  schema_module = get_schema_module_path(schema_version_postfix)
447
479
  importlib.import_module(schema_module)
@@ -449,20 +481,24 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]:
449
481
 
450
482
  with (
451
483
  patch.object(recorder, "db_schema", old_db_schema),
484
+ patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION),
452
485
  patch.object(
453
- recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION
486
+ migration,
487
+ "LIVE_MIGRATION_MIN_SCHEMA_VERSION",
488
+ get_patched_live_version(old_db_schema),
454
489
  ),
490
+ patch.object(migration, "non_live_data_migration_needed", return_value=False),
455
491
  patch.object(core, "StatesMeta", old_db_schema.StatesMeta),
456
492
  patch.object(core, "EventTypes", old_db_schema.EventTypes),
457
493
  patch.object(core, "EventData", old_db_schema.EventData),
458
494
  patch.object(core, "States", old_db_schema.States),
459
495
  patch.object(core, "Events", old_db_schema.Events),
460
496
  patch.object(core, "StateAttributes", old_db_schema.StateAttributes),
461
- patch.object(migration.EntityIDMigration, "task", MockMigrationTask),
462
497
  patch(
463
498
  CREATE_ENGINE_TARGET,
464
499
  new=partial(
465
500
  create_engine_test_for_schema_version_postfix,
501
+ hass=hass,
466
502
  schema_version_postfix=schema_version_postfix,
467
503
  ),
468
504
  ),
@@ -481,3 +517,97 @@ async def async_attach_db_engine(hass: HomeAssistant) -> None:
481
517
  )
482
518
 
483
519
  await instance.async_add_executor_job(_mock_setup_recorder_connection)
520
+
521
+
522
+ EVENT_ORIGIN_ORDER = [ha.EventOrigin.local, ha.EventOrigin.remote]
523
+
524
+
525
+ def db_event_to_native(event: Events, validate_entity_id: bool = True) -> Event | None:
526
+ """Convert to a native HA Event."""
527
+ context = ha.Context(
528
+ id=bytes_to_ulid_or_none(event.context_id_bin),
529
+ user_id=bytes_to_uuid_hex_or_none(event.context_user_id_bin),
530
+ parent_id=bytes_to_ulid_or_none(event.context_parent_id_bin),
531
+ )
532
+ return Event(
533
+ event.event_type or "",
534
+ json_loads_object(event.event_data) if event.event_data else {},
535
+ ha.EventOrigin(event.origin)
536
+ if event.origin
537
+ else EVENT_ORIGIN_ORDER[event.origin_idx or 0],
538
+ event.time_fired_ts or 0,
539
+ context=context,
540
+ )
541
+
542
+
543
+ def db_event_data_to_native(event_data: EventData) -> dict[str, Any]:
544
+ """Convert to an event data dictionary."""
545
+ shared_data = event_data.shared_data
546
+ if shared_data is None:
547
+ return {}
548
+ return cast(dict[str, Any], json_loads(shared_data))
549
+
550
+
551
+ def db_state_to_native(state: States, validate_entity_id: bool = True) -> State | None:
552
+ """Convert to an HA state object."""
553
+ context = ha.Context(
554
+ id=bytes_to_ulid_or_none(state.context_id_bin),
555
+ user_id=bytes_to_uuid_hex_or_none(state.context_user_id_bin),
556
+ parent_id=bytes_to_ulid_or_none(state.context_parent_id_bin),
557
+ )
558
+ attrs = json_loads_object(state.attributes) if state.attributes else {}
559
+ last_updated = dt_util.utc_from_timestamp(state.last_updated_ts or 0)
560
+ if state.last_changed_ts is None or state.last_changed_ts == state.last_updated_ts:
561
+ last_changed = dt_util.utc_from_timestamp(state.last_updated_ts or 0)
562
+ else:
563
+ last_changed = dt_util.utc_from_timestamp(state.last_changed_ts or 0)
564
+ if (
565
+ state.last_reported_ts is None
566
+ or state.last_reported_ts == state.last_updated_ts
567
+ ):
568
+ last_reported = dt_util.utc_from_timestamp(state.last_updated_ts or 0)
569
+ else:
570
+ last_reported = dt_util.utc_from_timestamp(state.last_reported_ts or 0)
571
+ return State(
572
+ state.entity_id or "",
573
+ state.state, # type: ignore[arg-type]
574
+ # Join the state_attributes table on attributes_id to get the attributes
575
+ # for newer states
576
+ attrs,
577
+ last_changed=last_changed,
578
+ last_reported=last_reported,
579
+ last_updated=last_updated,
580
+ context=context,
581
+ validate_entity_id=validate_entity_id,
582
+ )
583
+
584
+
585
+ def db_state_attributes_to_native(state_attrs: StateAttributes) -> dict[str, Any]:
586
+ """Convert to a state attributes dictionary."""
587
+ shared_attrs = state_attrs.shared_attrs
588
+ if shared_attrs is None:
589
+ return {}
590
+ return cast(dict[str, Any], json_loads(shared_attrs))
591
+
592
+
593
+ async def async_drop_index(
594
+ recorder: Recorder, table: str, index: str, caplog: pytest.LogCaptureFixture
595
+ ) -> None:
596
+ """Drop an index from the database.
597
+
598
+ migration._drop_index does not return or raise, so we verify the result
599
+ by checking the log for success or failure messages.
600
+ """
601
+
602
+ finish_msg = f"Finished dropping index `{index}` from table `{table}`"
603
+ fail_msg = f"Failed to drop index `{index}` from table `{table}`"
604
+
605
+ count_finish = caplog.text.count(finish_msg)
606
+ count_fail = caplog.text.count(fail_msg)
607
+
608
+ await recorder.async_add_executor_job(
609
+ migration._drop_index, recorder.get_session, table, index
610
+ )
611
+
612
+ assert caplog.text.count(finish_msg) == count_finish + 1
613
+ assert caplog.text.count(fail_msg) == count_fail
@@ -27,7 +27,7 @@ from sqlalchemy.orm.session import Session
27
27
 
28
28
  from homeassistant.core import Event, EventOrigin, State, split_entity_id
29
29
  from homeassistant.helpers.json import JSONEncoder
30
- import homeassistant.util.dt as dt_util
30
+ from homeassistant.util import dt as dt_util
31
31
 
32
32
  # SQLAlchemy Schema
33
33
  Base = declarative_base()