preact-homeassistant 0.2.2 → 0.3.0

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.
package/src/HAContext.tsx CHANGED
@@ -2,7 +2,7 @@ import { createContext } from 'preact';
2
2
  import type { ComponentChildren } from 'preact';
3
3
  import { useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
4
4
 
5
- import { loadFromCache, saveToCache } from './cacheUtils';
5
+ import { type Cache, readCache, writeCache } from './cacheUtils';
6
6
  import type {
7
7
  CalendarEvent,
8
8
  CalendarEventWithSource,
@@ -15,10 +15,19 @@ import type {
15
15
  } from './types';
16
16
  import { useCallbackStable } from './useCallbackStable';
17
17
 
18
+ type SubscribeToHass = (callback: () => void) => () => void;
19
+
20
+ // Default for providers that don't wire up hass-value notifications (Storybook,
21
+ // tests). useHassValue then simply returns its initial value and never updates.
22
+ const noopSubscribeToHass: SubscribeToHass = () => () => {};
23
+
18
24
  interface HAStore {
19
- hass: HomeAssistant | undefined;
20
25
  getHass: () => HomeAssistant | undefined;
21
26
  subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;
27
+ subscribeToHass: SubscribeToHass;
28
+ // Per-card cache (events, forecasts, entities). Owned by the provider so its
29
+ // lifetime is the card's — GC'd with the store when the card is torn down.
30
+ cache: Cache;
22
31
  }
23
32
 
24
33
  const HAContext = createContext<HAStore | null>(null);
@@ -26,22 +35,38 @@ const HAContext = createContext<HAStore | null>(null);
26
35
  interface HAProviderProps {
27
36
  hass: HomeAssistant | undefined;
28
37
  subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;
38
+ subscribeToHass?: SubscribeToHass;
39
+ // Optional injected cache (tests seed/inspect it); defaults to a fresh
40
+ // per-provider Map held stable across re-renders.
41
+ cache?: Cache;
29
42
  children: ComponentChildren;
30
43
  }
31
44
 
32
- export function HAProvider({ hass, subscribeToEntity, children }: HAProviderProps) {
45
+ export function HAProvider({
46
+ hass,
47
+ subscribeToEntity,
48
+ subscribeToHass,
49
+ cache,
50
+ children,
51
+ }: HAProviderProps) {
33
52
  const hassRef = useRef(hass);
34
53
  hassRef.current = hass;
35
54
 
36
55
  const getHass = useCallbackStable(() => hassRef.current);
37
56
 
57
+ const cacheRef = useRef<Cache>();
58
+ if (!cacheRef.current) cacheRef.current = cache ?? new Map();
59
+
60
+ const resolvedSubscribeToHass = subscribeToHass ?? noopSubscribeToHass;
61
+
38
62
  const store = useMemo<HAStore>(
39
63
  () => ({
40
- hass: hassRef.current,
41
64
  getHass,
42
65
  subscribeToEntity,
66
+ subscribeToHass: resolvedSubscribeToHass,
67
+ cache: cacheRef.current!,
43
68
  }),
44
- [getHass, subscribeToEntity],
69
+ [getHass, subscribeToEntity, resolvedSubscribeToHass],
45
70
  );
46
71
 
47
72
  return <HAContext.Provider value={store}>{children}</HAContext.Provider>;
@@ -69,18 +94,18 @@ export function useEntity<T extends string>(entityId: T): EntityForId<T> | undef
69
94
  const cacheKey = `entity:${entityId}`;
70
95
 
71
96
  const [entity, setEntity] = useState<EntityForId<T> | undefined>(() => {
72
- const current = store.hass?.states[entityId] as EntityForId<T> | undefined;
97
+ const current = store.getHass()?.states[entityId] as EntityForId<T> | undefined;
73
98
  if (current) return current;
74
- return loadFromCache<EntityForId<T>>(cacheKey);
99
+ return readCache<EntityForId<T>>(store.cache, cacheKey);
75
100
  });
76
101
 
77
102
  useEffect(() => {
78
103
  const unsubscribe = store.subscribeToEntity(entityId, (newEntity) => {
79
104
  setEntity(newEntity as EntityForId<T>);
80
- saveToCache(cacheKey, newEntity);
105
+ writeCache(store.cache, cacheKey, newEntity);
81
106
  });
82
107
  return unsubscribe;
83
- }, [entityId, store.subscribeToEntity, cacheKey]);
108
+ }, [entityId, store.subscribeToEntity, store.cache, cacheKey]);
84
109
 
85
110
  return entity;
86
111
  }
@@ -94,6 +119,47 @@ export function useHass(): { getHass: () => HomeAssistant | undefined } {
94
119
  return { getHass: store.getHass };
95
120
  }
96
121
 
122
+ /**
123
+ * Subscribe to a derived slice of the `hass` object (e.g. config, themes) and
124
+ * re-render only when that slice changes. Use this for non-entity values —
125
+ * entity state goes through `useEntity`. The selector runs on every hass update
126
+ * but only re-renders the consumer when `isEqual` reports a change, so it's
127
+ * cheap for rarely-changing values like config/themes.
128
+ */
129
+ export function useHassValue<T>(
130
+ selector: (hass: HomeAssistant | undefined) => T,
131
+ isEqual: (a: T, b: T) => boolean = Object.is,
132
+ ): T {
133
+ const store = useHAStore();
134
+
135
+ const selectorRef = useRef(selector);
136
+ selectorRef.current = selector;
137
+ const isEqualRef = useRef(isEqual);
138
+ isEqualRef.current = isEqual;
139
+
140
+ const [value, setValue] = useState<T>(() => selectorRef.current(store.getHass()));
141
+
142
+ useEffect(() => {
143
+ const unsubscribe = store.subscribeToHass(() => {
144
+ const next = selectorRef.current(store.getHass());
145
+ setValue((prev) => (isEqualRef.current(prev, next) ? prev : next));
146
+ });
147
+ return unsubscribe;
148
+ }, [store.subscribeToHass, store.getHass]);
149
+
150
+ return value;
151
+ }
152
+
153
+ /** Re-renders when `hass.config` changes (units, latitude/longitude, etc.). */
154
+ export function useHassConfig(): HomeAssistant['config'] | undefined {
155
+ return useHassValue((hass) => hass?.config);
156
+ }
157
+
158
+ /** Re-renders when the active theme's dark mode flips. */
159
+ export function useDarkMode(): boolean {
160
+ return useHassValue((hass) => hass?.themes?.darkMode ?? false);
161
+ }
162
+
97
163
  type ServiceCaller<T extends string> = <S extends keyof ServicesForId<T> & string>(
98
164
  service: S,
99
165
  ...args: ServicesForId<T>[S] extends undefined
@@ -140,11 +206,27 @@ export function useCachedFetch<T>(
140
206
  fetcher: () => Promise<T>,
141
207
  deps: unknown[],
142
208
  ): UseCachedFetchResult<T> {
143
- const [data, setData] = useState<T | undefined>(() => loadFromCache<T>(cacheKey));
209
+ const store = useHAStore();
210
+ const [data, setData] = useState<T | undefined>(() => readCache<T>(store.cache, cacheKey));
144
211
  const [isFresh, setIsFresh] = useState(false);
145
212
  const [isFetching, setIsFetching] = useState(false);
146
213
  const [error, setError] = useState<Error | undefined>(undefined);
147
214
 
215
+ // Stale-while-revalidate, key-change aware. When `cacheKey` changes we swap to
216
+ // the new key's cached value synchronously (SWR hit) or keep the previously
217
+ // rendered data (keep-previous-data on a cold key) — never blanking to a
218
+ // loading state. The `deps` effect below issues the background refetch; the
219
+ // only `'loading'` state is a true cold start (nothing cached, nothing fetched).
220
+ const dataKeyRef = useRef(cacheKey);
221
+ if (cacheKey !== dataKeyRef.current) {
222
+ dataKeyRef.current = cacheKey;
223
+ setIsFresh(false);
224
+ setError(undefined);
225
+ const cached = readCache<T>(store.cache, cacheKey);
226
+ if (cached !== undefined) setData(cached);
227
+ // cache miss: leave `data` as-is (keep-previous-data)
228
+ }
229
+
148
230
  const fetchIdRef = useRef(0);
149
231
 
150
232
  const doFetch = useCallbackStable(async () => {
@@ -157,7 +239,7 @@ export function useCachedFetch<T>(
157
239
  if (fetchId === fetchIdRef.current) {
158
240
  setData(result);
159
241
  setIsFresh(true);
160
- saveToCache(cacheKey, result);
242
+ writeCache(store.cache, cacheKey, result);
161
243
  }
162
244
  } catch (err) {
163
245
  if (fetchId === fetchIdRef.current) {
@@ -172,7 +254,6 @@ export function useCachedFetch<T>(
172
254
 
173
255
  useEffect(() => {
174
256
  doFetch();
175
- // eslint-disable-next-line react-hooks/exhaustive-deps
176
257
  }, deps);
177
258
 
178
259
  const status: FetchStatus = useMemo(() => {
@@ -186,87 +267,77 @@ export function useCachedFetch<T>(
186
267
  }
187
268
 
188
269
  interface UseCalendarEventsResult {
189
- events: CalendarEvent[] | undefined;
190
- loading: boolean;
270
+ events: CalendarEventWithSource[] | undefined;
271
+ status: FetchStatus;
191
272
  error: Error | undefined;
192
273
  refetch: () => void;
274
+ /**
275
+ * Warm the cache for an arbitrary range (e.g. adjacent months) without
276
+ * touching component state. Best-effort: skips ranges already cached and
277
+ * swallows failures.
278
+ */
279
+ prefetch: (range: { start: Date; end: Date }) => void;
193
280
  }
194
281
 
195
- /**
196
- * Fetch calendar events for a date range from a single calendar.
197
- */
198
- export function useCalendarEvents(
199
- entityId: `calendar.${string}`,
200
- options: { start: Date; end: Date },
201
- ): UseCalendarEventsResult {
202
- const { getHass } = useHass();
203
- const [events, setEvents] = useState<CalendarEvent[] | undefined>(undefined);
204
- const [loading, setLoading] = useState(false);
205
- const [error, setError] = useState<Error | undefined>(undefined);
206
-
207
- const fetchIdRef = useRef(0);
208
-
209
- const fetchEvents = useCallbackStable(async () => {
210
- const hass = getHass();
211
- if (!hass?.connection) {
212
- setError(new Error('Home Assistant connection not available'));
213
- return;
214
- }
215
-
216
- const fetchId = ++fetchIdRef.current;
217
- setLoading(true);
218
- setError(undefined);
282
+ function calendarEventsCacheKey(
283
+ entityIds: `calendar.${string}`[],
284
+ range: { start: Date; end: Date },
285
+ ): string {
286
+ return `events:${entityIds.join(',')}:${range.start.getTime()}-${range.end.getTime()}`;
287
+ }
219
288
 
220
- try {
221
- const result = await hass.connection.sendMessagePromise<{
222
- response: { [entityId: string]: { events: CalendarEvent[] } };
223
- }>({
224
- type: 'call_service',
225
- domain: 'calendar',
226
- service: 'get_events',
227
- service_data: {
228
- start_date_time: options.start.toISOString(),
229
- end_date_time: options.end.toISOString(),
230
- },
231
- target: { entity_id: entityId },
232
- return_response: true,
233
- });
289
+ async function fetchCalendarRange(
290
+ hass: HomeAssistant | undefined,
291
+ entityIds: `calendar.${string}`[],
292
+ range: { start: Date; end: Date },
293
+ ): Promise<CalendarEventWithSource[]> {
294
+ if (!hass?.connection) {
295
+ throw new Error('Home Assistant connection not available');
296
+ }
297
+ if (entityIds.length === 0) {
298
+ return [];
299
+ }
234
300
 
235
- if (fetchId === fetchIdRef.current) {
236
- const entityEvents = result.response?.[entityId]?.events ?? [];
237
- setEvents(entityEvents);
238
- setLoading(false);
301
+ const results = await Promise.all(
302
+ entityIds.map(async (entityId) => {
303
+ try {
304
+ const result = await hass.connection.sendMessagePromise<{
305
+ response: { [key: string]: { events: CalendarEvent[] } };
306
+ }>({
307
+ type: 'call_service',
308
+ domain: 'calendar',
309
+ service: 'get_events',
310
+ service_data: {
311
+ start_date_time: range.start.toISOString(),
312
+ end_date_time: range.end.toISOString(),
313
+ },
314
+ target: { entity_id: entityId },
315
+ return_response: true,
316
+ });
317
+
318
+ const calendarEvents = result.response?.[entityId]?.events ?? [];
319
+ return calendarEvents.map(
320
+ (event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),
321
+ );
322
+ } catch (err) {
323
+ console.error(`Failed to fetch events for ${entityId}:`, err);
324
+ return [];
239
325
  }
240
- } catch (err) {
241
- if (fetchId === fetchIdRef.current) {
242
- setError(err instanceof Error ? err : new Error(String(err)));
243
- setLoading(false);
244
- }
245
- }
246
- });
247
-
248
- useEffect(() => {
249
- fetchEvents();
250
- }, [entityId, options.start.getTime(), options.end.getTime(), fetchEvents]);
326
+ }),
327
+ );
251
328
 
252
- return { events, loading, error, refetch: fetchEvents };
253
- }
254
-
255
- interface UseMultiCalendarEventsResult {
256
- events: CalendarEventWithSource[] | undefined;
257
- status: FetchStatus;
258
- error: Error | undefined;
259
- refetch: () => void;
329
+ return results.flat();
260
330
  }
261
331
 
262
332
  /**
263
- * Fetch events from multiple calendars for a date range, with localStorage
264
- * caching. Events are tagged with their source calendar ID.
333
+ * Fetch events from one or more calendars for a date range, with in-memory
334
+ * (per-card) caching and stale-while-revalidate behavior. Events are tagged
335
+ * with their source calendar ID. Returns `prefetch` to warm adjacent ranges.
265
336
  */
266
- export function useMultiCalendarEvents(
337
+ export function useCalendarEvents(
267
338
  entityIds: `calendar.${string}`[],
268
339
  options: { start: Date; end: Date },
269
- ): UseMultiCalendarEventsResult {
340
+ ): UseCalendarEventsResult {
270
341
  const store = useHAStore();
271
342
  const { getHass } = useHass();
272
343
 
@@ -276,46 +347,7 @@ export function useMultiCalendarEvents(
276
347
  const dateRangeKey = `${options.start.getTime()}-${options.end.getTime()}`;
277
348
  const cacheKey = `events:${entityIdsKey}:${dateRangeKey}`;
278
349
 
279
- const fetcher = useCallbackStable(async () => {
280
- const hass = getHass();
281
- if (!hass?.connection) {
282
- throw new Error('Home Assistant connection not available');
283
- }
284
-
285
- if (entityIds.length === 0) {
286
- return [];
287
- }
288
-
289
- const results = await Promise.all(
290
- entityIds.map(async (entityId) => {
291
- try {
292
- const result = await hass.connection.sendMessagePromise<{
293
- response: { [key: string]: { events: CalendarEvent[] } };
294
- }>({
295
- type: 'call_service',
296
- domain: 'calendar',
297
- service: 'get_events',
298
- service_data: {
299
- start_date_time: options.start.toISOString(),
300
- end_date_time: options.end.toISOString(),
301
- },
302
- target: { entity_id: entityId },
303
- return_response: true,
304
- });
305
-
306
- const calendarEvents = result.response?.[entityId]?.events ?? [];
307
- return calendarEvents.map(
308
- (event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),
309
- );
310
- } catch (err) {
311
- console.error(`Failed to fetch events for ${entityId}:`, err);
312
- return [];
313
- }
314
- }),
315
- );
316
-
317
- return results.flat();
318
- });
350
+ const fetcher = useCallbackStable(() => fetchCalendarRange(getHass(), entityIds, options));
319
351
 
320
352
  const {
321
353
  data: events,
@@ -324,6 +356,16 @@ export function useMultiCalendarEvents(
324
356
  refetch,
325
357
  } = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey]);
326
358
 
359
+ const prefetch = useCallbackStable((range: { start: Date; end: Date }) => {
360
+ const key = calendarEventsCacheKey(entityIds, range);
361
+ if (store.cache.has(key)) return; // already warm
362
+ fetchCalendarRange(getHass(), entityIds, range)
363
+ .then((result) => writeCache(store.cache, key, result))
364
+ .catch(() => {
365
+ // best-effort prefetch; ignore failures
366
+ });
367
+ });
368
+
327
369
  const debouncedRefetch = useCallbackStable(() => {
328
370
  if (debounceTimerRef.current) {
329
371
  clearTimeout(debounceTimerRef.current);
@@ -343,7 +385,7 @@ export function useMultiCalendarEvents(
343
385
  };
344
386
  }, [entityIdsKey, store.subscribeToEntity, debouncedRefetch]);
345
387
 
346
- return { events, status, error, refetch };
388
+ return { events, status, error, refetch, prefetch };
347
389
  }
348
390
 
349
391
  interface UseWeatherForecastResult {
@@ -1,61 +1,36 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { loadFromCache, saveToCache } from '../cacheUtils';
1
+ import { describe, expect, it } from 'vitest';
2
+ import { type Cache, readCache, writeCache } from '../cacheUtils';
3
3
 
4
4
  describe('cacheUtils', () => {
5
- beforeEach(() => {
6
- localStorage.clear();
7
- });
8
-
9
- it('round-trips data through save and load', () => {
10
- saveToCache('test-key', { foo: 'bar' });
11
- expect(loadFromCache('test-key')).toEqual({ foo: 'bar' });
5
+ it('round-trips data through write and read', () => {
6
+ const cache: Cache = new Map();
7
+ writeCache(cache, 'test-key', { foo: 'bar' });
8
+ expect(readCache(cache, 'test-key')).toEqual({ foo: 'bar' });
12
9
  });
13
10
 
14
11
  it('returns undefined for missing keys', () => {
15
- expect(loadFromCache('nonexistent')).toBeUndefined();
16
- });
17
-
18
- it('returns undefined for expired entries', () => {
19
- saveToCache('old', 'data');
20
-
21
- // Patch the stored timestamp to 25 hours ago
22
- const raw = localStorage.getItem('preact-ha:old')!;
23
- const entry = JSON.parse(raw);
24
- entry.timestamp = Date.now() - 25 * 60 * 60 * 1000;
25
- localStorage.setItem('preact-ha:old', JSON.stringify(entry));
26
-
27
- expect(loadFromCache('old')).toBeUndefined();
12
+ const cache: Cache = new Map();
13
+ expect(readCache(cache, 'nonexistent')).toBeUndefined();
28
14
  });
29
15
 
30
- it('removes expired entries from localStorage', () => {
31
- saveToCache('old', 'data');
32
-
33
- const raw = localStorage.getItem('preact-ha:old')!;
34
- const entry = JSON.parse(raw);
35
- entry.timestamp = Date.now() - 25 * 60 * 60 * 1000;
36
- localStorage.setItem('preact-ha:old', JSON.stringify(entry));
37
-
38
- loadFromCache('old');
39
- expect(localStorage.getItem('preact-ha:old')).toBeNull();
16
+ it('overwrites an existing entry', () => {
17
+ const cache: Cache = new Map();
18
+ writeCache(cache, 'k', 1);
19
+ writeCache(cache, 'k', 2);
20
+ expect(readCache(cache, 'k')).toBe(2);
40
21
  });
41
22
 
42
- it('returns undefined for corrupted JSON', () => {
43
- localStorage.setItem('preact-ha:bad', 'not json');
44
- expect(loadFromCache('bad')).toBeUndefined();
23
+ it('isolates entries between separate cache maps', () => {
24
+ const a: Cache = new Map();
25
+ const b: Cache = new Map();
26
+ writeCache(a, 'k', 'a-value');
27
+ expect(readCache(b, 'k')).toBeUndefined();
45
28
  });
46
29
 
47
- it('handles localStorage write failures gracefully', () => {
48
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
49
- const originalSetItem = localStorage.setItem;
50
- localStorage.setItem = () => {
51
- throw new Error('QuotaExceededError');
52
- };
53
-
54
- // Should not throw
55
- saveToCache('key', 'value');
56
- expect(warnSpy).toHaveBeenCalled();
57
-
58
- localStorage.setItem = originalSetItem;
59
- vi.restoreAllMocks();
30
+ it('stores undefined data without conflating it with a missing key', () => {
31
+ const cache: Cache = new Map();
32
+ writeCache(cache, 'k', undefined);
33
+ expect(cache.has('k')).toBe(true);
34
+ expect(readCache(cache, 'k')).toBeUndefined();
60
35
  });
61
36
  });
@@ -216,4 +216,79 @@ describe('registerPreactCard', () => {
216
216
  const CardClass = customElements.get(type) as any;
217
217
  expect(CardClass.getStubConfig()).toEqual({ entities: [] });
218
218
  });
219
+
220
+ it('tears down the Preact tree after the grace period when removed', () => {
221
+ vi.useFakeTimers();
222
+ const type = uniqueType();
223
+ registerPreactCard({ type, name: 'Test', description: 'Test', Component: TestComponent });
224
+
225
+ const card = document.createElement(type) as any;
226
+ document.body.appendChild(card);
227
+ card.setConfig({ entities: ['sensor.temp'] });
228
+ card.hass = makeHass({ 'sensor.temp': { state: '72' } });
229
+ expect(card.shadowRoot.textContent).toContain('test');
230
+
231
+ document.body.removeChild(card);
232
+
233
+ // Within the grace window: still mounted.
234
+ vi.advanceTimersByTime(4000);
235
+ expect(card.shadowRoot.textContent).toContain('test');
236
+
237
+ // Past the grace window: torn down (render(null) clears the shadow root).
238
+ vi.advanceTimersByTime(2000);
239
+ expect(card.shadowRoot.textContent).toBe('');
240
+
241
+ vi.useRealTimers();
242
+ });
243
+
244
+ it('cancels teardown when reconnected within the grace period', () => {
245
+ vi.useFakeTimers();
246
+ const type = uniqueType();
247
+ registerPreactCard({ type, name: 'Test', description: 'Test', Component: TestComponent });
248
+
249
+ const card = document.createElement(type) as any;
250
+ document.body.appendChild(card);
251
+ card.setConfig({ entities: ['sensor.temp'] });
252
+ card.hass = makeHass({ 'sensor.temp': { state: '72' } });
253
+
254
+ document.body.removeChild(card);
255
+ vi.advanceTimersByTime(4000); // still within grace
256
+ document.body.appendChild(card); // reconnect cancels the scheduled teardown
257
+
258
+ vi.advanceTimersByTime(4000); // 8000ms total since removal — would have torn down
259
+ expect(card.shadowRoot.textContent).toContain('test');
260
+
261
+ vi.useRealTimers();
262
+ });
263
+
264
+ it('re-renders the editor on every hass update', () => {
265
+ const type = uniqueType();
266
+ let editorRenders = 0;
267
+ function TestEditor() {
268
+ editorRenders++;
269
+ return <div>editor</div>;
270
+ }
271
+
272
+ registerPreactCard({
273
+ type,
274
+ name: 'Test',
275
+ description: 'Test',
276
+ Component: TestComponent,
277
+ ConfigComponent: TestEditor,
278
+ });
279
+
280
+ const editor = document.createElement(`${type}-editor`) as any;
281
+ document.body.appendChild(editor);
282
+ editor.setConfig({ entities: [] });
283
+
284
+ editor.hass = makeHass({});
285
+ const afterFirstHass = editorRenders;
286
+ expect(afterFirstHass).toBeGreaterThan(0);
287
+
288
+ editor.hass = makeHass({ 'sensor.x': { state: '1' } });
289
+ expect(editorRenders).toBeGreaterThan(afterFirstHass);
290
+
291
+ editor.hass = makeHass({ 'sensor.x': { state: '2' } });
292
+ expect(editorRenders).toBeGreaterThan(afterFirstHass + 1);
293
+ });
219
294
  });
@@ -49,18 +49,28 @@ export function createMockSubscribe() {
49
49
  interface RenderWithHAOptions extends Omit<RenderOptions, 'wrapper'> {
50
50
  hass?: HomeAssistant;
51
51
  subscribeFn?: MockSubscribeFn;
52
+ subscribeToHass?: (cb: () => void) => () => void;
53
+ // Inject a per-card cache so tests can seed/inspect it.
54
+ cache?: Map<string, { data: unknown }>;
52
55
  }
53
56
 
54
57
  export function renderWithHA(ui: ComponentChildren, options: RenderWithHAOptions = {}) {
55
58
  const {
56
59
  hass = makeHass(),
57
60
  subscribeFn = createMockSubscribe().subscribe,
61
+ subscribeToHass,
62
+ cache,
58
63
  ...renderOptions
59
64
  } = options;
60
65
 
61
66
  function Wrapper({ children }: { children: ComponentChildren }) {
62
67
  return (
63
- <HAProvider hass={hass} subscribeToEntity={subscribeFn}>
68
+ <HAProvider
69
+ hass={hass}
70
+ subscribeToEntity={subscribeFn}
71
+ subscribeToHass={subscribeToHass}
72
+ cache={cache}
73
+ >
64
74
  {children}
65
75
  </HAProvider>
66
76
  );