preact-homeassistant 0.1.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/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/index.d.ts +276 -0
- package/dist/index.js +459 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/src/HAContext.tsx +407 -0
- package/src/__tests__/cacheUtils.test.ts +61 -0
- package/src/__tests__/registerPreactCard.test.tsx +213 -0
- package/src/__tests__/setup.ts +27 -0
- package/src/__tests__/testHelpers.tsx +73 -0
- package/src/__tests__/useCachedFetch.test.tsx +147 -0
- package/src/__tests__/useEntity.test.tsx +140 -0
- package/src/__tests__/useMultiCalendarEvents.test.tsx +217 -0
- package/src/__tests__/useWeatherForecast.test.tsx +197 -0
- package/src/cacheUtils.ts +32 -0
- package/src/index.ts +27 -0
- package/src/registerPreactCard.tsx +191 -0
- package/src/styleRegistry.ts +31 -0
- package/src/types/calendar.ts +40 -0
- package/src/types/common.ts +23 -0
- package/src/types/index.ts +38 -0
- package/src/types/sun.ts +20 -0
- package/src/types/weather.ts +42 -0
- package/src/useCallbackStable.ts +24 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { createContext } from 'preact';
|
|
2
|
+
import type { ComponentChildren } from 'preact';
|
|
3
|
+
import { useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
4
|
+
|
|
5
|
+
import { loadFromCache, saveToCache } from './cacheUtils';
|
|
6
|
+
import type {
|
|
7
|
+
CalendarEvent,
|
|
8
|
+
CalendarEventWithSource,
|
|
9
|
+
EntityForId,
|
|
10
|
+
FetchStatus,
|
|
11
|
+
ForecastType,
|
|
12
|
+
HomeAssistant,
|
|
13
|
+
WeatherForecast,
|
|
14
|
+
} from './types';
|
|
15
|
+
import { useCallbackStable } from './useCallbackStable';
|
|
16
|
+
|
|
17
|
+
interface HAStore {
|
|
18
|
+
hass: HomeAssistant | undefined;
|
|
19
|
+
getHass: () => HomeAssistant | undefined;
|
|
20
|
+
subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const HAContext = createContext<HAStore | null>(null);
|
|
24
|
+
|
|
25
|
+
interface HAProviderProps {
|
|
26
|
+
hass: HomeAssistant | undefined;
|
|
27
|
+
subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;
|
|
28
|
+
children: ComponentChildren;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function HAProvider({ hass, subscribeToEntity, children }: HAProviderProps) {
|
|
32
|
+
const hassRef = useRef(hass);
|
|
33
|
+
hassRef.current = hass;
|
|
34
|
+
|
|
35
|
+
const getHass = useCallbackStable(() => hassRef.current);
|
|
36
|
+
|
|
37
|
+
const store = useMemo<HAStore>(
|
|
38
|
+
() => ({
|
|
39
|
+
hass: hassRef.current,
|
|
40
|
+
getHass,
|
|
41
|
+
subscribeToEntity,
|
|
42
|
+
}),
|
|
43
|
+
[getHass, subscribeToEntity],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return <HAContext.Provider value={store}>{children}</HAContext.Provider>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function useHAStore(): HAStore {
|
|
50
|
+
const store = useContext(HAContext);
|
|
51
|
+
if (!store) {
|
|
52
|
+
throw new Error('useEntity/useHass must be used within an HAProvider');
|
|
53
|
+
}
|
|
54
|
+
return store;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Subscribe to a specific entity by ID. Re-renders only when that entity changes.
|
|
59
|
+
*
|
|
60
|
+
* Returns a typed entity based on the domain prefix:
|
|
61
|
+
* - 'calendar.xyz' -> CalendarEntity
|
|
62
|
+
* - 'weather.xyz' -> WeatherEntity
|
|
63
|
+
* - 'sun.sun' -> SunEntity
|
|
64
|
+
* - other domains -> HassEntity (fallback)
|
|
65
|
+
*/
|
|
66
|
+
export function useEntity<T extends string>(entityId: T): EntityForId<T> | undefined {
|
|
67
|
+
const store = useHAStore();
|
|
68
|
+
const cacheKey = `entity:${entityId}`;
|
|
69
|
+
|
|
70
|
+
const [entity, setEntity] = useState<EntityForId<T> | undefined>(() => {
|
|
71
|
+
const current = store.hass?.states[entityId] as EntityForId<T> | undefined;
|
|
72
|
+
if (current) return current;
|
|
73
|
+
return loadFromCache<EntityForId<T>>(cacheKey);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const unsubscribe = store.subscribeToEntity(entityId, (newEntity) => {
|
|
78
|
+
setEntity(newEntity as EntityForId<T>);
|
|
79
|
+
saveToCache(cacheKey, newEntity);
|
|
80
|
+
});
|
|
81
|
+
return unsubscribe;
|
|
82
|
+
}, [entityId, store.subscribeToEntity, cacheKey]);
|
|
83
|
+
|
|
84
|
+
return entity;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get access to the full hass object for calling services / accessing config.
|
|
89
|
+
* Does NOT re-render on entity changes. Use useEntity for that.
|
|
90
|
+
*/
|
|
91
|
+
export function useHass(): { getHass: () => HomeAssistant | undefined } {
|
|
92
|
+
const store = useHAStore();
|
|
93
|
+
return { getHass: store.getHass };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface UseCachedFetchResult<T> {
|
|
97
|
+
data: T | undefined;
|
|
98
|
+
status: FetchStatus;
|
|
99
|
+
error: Error | undefined;
|
|
100
|
+
refetch: () => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generic hook for fetching data with localStorage caching. Returns a cache-aware
|
|
105
|
+
* status string to distinguish cached vs fresh data.
|
|
106
|
+
*/
|
|
107
|
+
export function useCachedFetch<T>(
|
|
108
|
+
cacheKey: string,
|
|
109
|
+
fetcher: () => Promise<T>,
|
|
110
|
+
deps: unknown[],
|
|
111
|
+
): UseCachedFetchResult<T> {
|
|
112
|
+
const [data, setData] = useState<T | undefined>(() => loadFromCache<T>(cacheKey));
|
|
113
|
+
const [isFresh, setIsFresh] = useState(false);
|
|
114
|
+
const [isFetching, setIsFetching] = useState(false);
|
|
115
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
116
|
+
|
|
117
|
+
const fetchIdRef = useRef(0);
|
|
118
|
+
|
|
119
|
+
const doFetch = useCallbackStable(async () => {
|
|
120
|
+
const fetchId = ++fetchIdRef.current;
|
|
121
|
+
setIsFetching(true);
|
|
122
|
+
setError(undefined);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const result = await fetcher();
|
|
126
|
+
if (fetchId === fetchIdRef.current) {
|
|
127
|
+
setData(result);
|
|
128
|
+
setIsFresh(true);
|
|
129
|
+
saveToCache(cacheKey, result);
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (fetchId === fetchIdRef.current) {
|
|
133
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
if (fetchId === fetchIdRef.current) {
|
|
137
|
+
setIsFetching(false);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
doFetch();
|
|
144
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
145
|
+
}, deps);
|
|
146
|
+
|
|
147
|
+
const status: FetchStatus = useMemo(() => {
|
|
148
|
+
if (!data && isFetching) return 'loading';
|
|
149
|
+
if (data && !isFresh && isFetching) return 'cached';
|
|
150
|
+
if (data && isFresh && isFetching) return 'refreshing';
|
|
151
|
+
return 'ready';
|
|
152
|
+
}, [data, isFresh, isFetching]);
|
|
153
|
+
|
|
154
|
+
return { data, status, error, refetch: doFetch };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface UseCalendarEventsResult {
|
|
158
|
+
events: CalendarEvent[] | undefined;
|
|
159
|
+
loading: boolean;
|
|
160
|
+
error: Error | undefined;
|
|
161
|
+
refetch: () => void;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Fetch calendar events for a date range from a single calendar.
|
|
166
|
+
*/
|
|
167
|
+
export function useCalendarEvents(
|
|
168
|
+
entityId: `calendar.${string}`,
|
|
169
|
+
options: { start: Date; end: Date },
|
|
170
|
+
): UseCalendarEventsResult {
|
|
171
|
+
const { getHass } = useHass();
|
|
172
|
+
const [events, setEvents] = useState<CalendarEvent[] | undefined>(undefined);
|
|
173
|
+
const [loading, setLoading] = useState(false);
|
|
174
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
175
|
+
|
|
176
|
+
const fetchIdRef = useRef(0);
|
|
177
|
+
|
|
178
|
+
const fetchEvents = useCallbackStable(async () => {
|
|
179
|
+
const hass = getHass();
|
|
180
|
+
if (!hass?.connection) {
|
|
181
|
+
setError(new Error('Home Assistant connection not available'));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fetchId = ++fetchIdRef.current;
|
|
186
|
+
setLoading(true);
|
|
187
|
+
setError(undefined);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const result = await hass.connection.sendMessagePromise<{
|
|
191
|
+
response: { [entityId: string]: { events: CalendarEvent[] } };
|
|
192
|
+
}>({
|
|
193
|
+
type: 'call_service',
|
|
194
|
+
domain: 'calendar',
|
|
195
|
+
service: 'get_events',
|
|
196
|
+
service_data: {
|
|
197
|
+
start_date_time: options.start.toISOString(),
|
|
198
|
+
end_date_time: options.end.toISOString(),
|
|
199
|
+
},
|
|
200
|
+
target: { entity_id: entityId },
|
|
201
|
+
return_response: true,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (fetchId === fetchIdRef.current) {
|
|
205
|
+
const entityEvents = result.response?.[entityId]?.events ?? [];
|
|
206
|
+
setEvents(entityEvents);
|
|
207
|
+
setLoading(false);
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (fetchId === fetchIdRef.current) {
|
|
211
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
212
|
+
setLoading(false);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
fetchEvents();
|
|
219
|
+
}, [entityId, options.start.getTime(), options.end.getTime(), fetchEvents]);
|
|
220
|
+
|
|
221
|
+
return { events, loading, error, refetch: fetchEvents };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface UseMultiCalendarEventsResult {
|
|
225
|
+
events: CalendarEventWithSource[] | undefined;
|
|
226
|
+
status: FetchStatus;
|
|
227
|
+
error: Error | undefined;
|
|
228
|
+
refetch: () => void;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Fetch events from multiple calendars for a date range, with localStorage
|
|
233
|
+
* caching. Events are tagged with their source calendar ID.
|
|
234
|
+
*/
|
|
235
|
+
export function useMultiCalendarEvents(
|
|
236
|
+
entityIds: `calendar.${string}`[],
|
|
237
|
+
options: { start: Date; end: Date },
|
|
238
|
+
): UseMultiCalendarEventsResult {
|
|
239
|
+
const store = useHAStore();
|
|
240
|
+
const { getHass } = useHass();
|
|
241
|
+
|
|
242
|
+
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
243
|
+
|
|
244
|
+
const entityIdsKey = entityIds.join(',');
|
|
245
|
+
const dateRangeKey = `${options.start.getTime()}-${options.end.getTime()}`;
|
|
246
|
+
const cacheKey = `events:${entityIdsKey}:${dateRangeKey}`;
|
|
247
|
+
|
|
248
|
+
const fetcher = useCallbackStable(async () => {
|
|
249
|
+
const hass = getHass();
|
|
250
|
+
if (!hass?.connection) {
|
|
251
|
+
throw new Error('Home Assistant connection not available');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (entityIds.length === 0) {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const results = await Promise.all(
|
|
259
|
+
entityIds.map(async (entityId) => {
|
|
260
|
+
try {
|
|
261
|
+
const result = await hass.connection.sendMessagePromise<{
|
|
262
|
+
response: { [key: string]: { events: CalendarEvent[] } };
|
|
263
|
+
}>({
|
|
264
|
+
type: 'call_service',
|
|
265
|
+
domain: 'calendar',
|
|
266
|
+
service: 'get_events',
|
|
267
|
+
service_data: {
|
|
268
|
+
start_date_time: options.start.toISOString(),
|
|
269
|
+
end_date_time: options.end.toISOString(),
|
|
270
|
+
},
|
|
271
|
+
target: { entity_id: entityId },
|
|
272
|
+
return_response: true,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const calendarEvents = result.response?.[entityId]?.events ?? [];
|
|
276
|
+
return calendarEvents.map(
|
|
277
|
+
(event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),
|
|
278
|
+
);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error(`Failed to fetch events for ${entityId}:`, err);
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
return results.flat();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const {
|
|
290
|
+
data: events,
|
|
291
|
+
status,
|
|
292
|
+
error,
|
|
293
|
+
refetch,
|
|
294
|
+
} = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey]);
|
|
295
|
+
|
|
296
|
+
const debouncedRefetch = useCallbackStable(() => {
|
|
297
|
+
if (debounceTimerRef.current) {
|
|
298
|
+
clearTimeout(debounceTimerRef.current);
|
|
299
|
+
}
|
|
300
|
+
debounceTimerRef.current = setTimeout(() => refetch(), 500);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
const unsubscribes = entityIds.map((entityId) =>
|
|
305
|
+
store.subscribeToEntity(entityId, debouncedRefetch),
|
|
306
|
+
);
|
|
307
|
+
return () => {
|
|
308
|
+
unsubscribes.forEach((unsub) => unsub());
|
|
309
|
+
if (debounceTimerRef.current) {
|
|
310
|
+
clearTimeout(debounceTimerRef.current);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}, [entityIdsKey, store.subscribeToEntity, debouncedRefetch]);
|
|
314
|
+
|
|
315
|
+
return { events, status, error, refetch };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface UseWeatherForecastResult {
|
|
319
|
+
forecast: WeatherForecast[] | undefined;
|
|
320
|
+
status: FetchStatus;
|
|
321
|
+
error: Error | undefined;
|
|
322
|
+
refetch: () => void;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Fetch weather forecast data with localStorage caching. Auto-refetches at the
|
|
327
|
+
* top of each hour and when the underlying entity changes (debounced).
|
|
328
|
+
*/
|
|
329
|
+
export function useWeatherForecast(
|
|
330
|
+
entityId: `weather.${string}`,
|
|
331
|
+
type: ForecastType,
|
|
332
|
+
): UseWeatherForecastResult {
|
|
333
|
+
const store = useHAStore();
|
|
334
|
+
const { getHass } = useHass();
|
|
335
|
+
const cacheKey = `forecast:${entityId}:${type}`;
|
|
336
|
+
|
|
337
|
+
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
338
|
+
const hourlyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
339
|
+
|
|
340
|
+
const fetcher = useCallbackStable(async () => {
|
|
341
|
+
const hass = getHass();
|
|
342
|
+
if (!hass?.connection) {
|
|
343
|
+
throw new Error('Home Assistant connection not available');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const result = await hass.connection.sendMessagePromise<{
|
|
347
|
+
response: { [entityId: string]: { forecast: WeatherForecast[] } };
|
|
348
|
+
}>({
|
|
349
|
+
type: 'call_service',
|
|
350
|
+
domain: 'weather',
|
|
351
|
+
service: 'get_forecasts',
|
|
352
|
+
service_data: { type },
|
|
353
|
+
target: { entity_id: entityId },
|
|
354
|
+
return_response: true,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return result.response?.[entityId]?.forecast ?? [];
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const {
|
|
361
|
+
data: forecast,
|
|
362
|
+
status,
|
|
363
|
+
error,
|
|
364
|
+
refetch,
|
|
365
|
+
} = useCachedFetch(cacheKey, fetcher, [entityId, type]);
|
|
366
|
+
|
|
367
|
+
const debouncedRefetch = useCallbackStable(() => {
|
|
368
|
+
if (debounceTimerRef.current) {
|
|
369
|
+
clearTimeout(debounceTimerRef.current);
|
|
370
|
+
}
|
|
371
|
+
debounceTimerRef.current = setTimeout(() => refetch(), 500);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const scheduleHourlyRefetch = useCallbackStable(() => {
|
|
375
|
+
if (hourlyTimerRef.current) {
|
|
376
|
+
clearTimeout(hourlyTimerRef.current);
|
|
377
|
+
}
|
|
378
|
+
const now = new Date();
|
|
379
|
+
const nextHour = new Date(now);
|
|
380
|
+
nextHour.setHours(now.getHours() + 1, 0, 0, 0);
|
|
381
|
+
const msUntilNextHour = nextHour.getTime() - now.getTime();
|
|
382
|
+
|
|
383
|
+
hourlyTimerRef.current = setTimeout(() => {
|
|
384
|
+
refetch();
|
|
385
|
+
scheduleHourlyRefetch();
|
|
386
|
+
}, msUntilNextHour);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
scheduleHourlyRefetch();
|
|
391
|
+
}, [entityId, type, scheduleHourlyRefetch]);
|
|
392
|
+
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
const unsubscribe = store.subscribeToEntity(entityId, debouncedRefetch);
|
|
395
|
+
return () => {
|
|
396
|
+
unsubscribe();
|
|
397
|
+
if (debounceTimerRef.current) {
|
|
398
|
+
clearTimeout(debounceTimerRef.current);
|
|
399
|
+
}
|
|
400
|
+
if (hourlyTimerRef.current) {
|
|
401
|
+
clearTimeout(hourlyTimerRef.current);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
}, [entityId, store.subscribeToEntity, debouncedRefetch]);
|
|
405
|
+
|
|
406
|
+
return { forecast, status, error, refetch };
|
|
407
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { loadFromCache, saveToCache } from '../cacheUtils';
|
|
3
|
+
|
|
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' });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
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();
|
|
28
|
+
});
|
|
29
|
+
|
|
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();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns undefined for corrupted JSON', () => {
|
|
43
|
+
localStorage.setItem('preact-ha:bad', 'not json');
|
|
44
|
+
expect(loadFromCache('bad')).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
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();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { registerPreactCard } from '../registerPreactCard';
|
|
3
|
+
import type { HomeAssistant } from '../types';
|
|
4
|
+
|
|
5
|
+
// Track renders and config
|
|
6
|
+
let lastConfig: any = undefined;
|
|
7
|
+
let renderCount = 0;
|
|
8
|
+
|
|
9
|
+
function TestComponent({ config }: { config: { entities: string[] } }) {
|
|
10
|
+
renderCount++;
|
|
11
|
+
lastConfig = config;
|
|
12
|
+
return <div>test</div>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function UnconfiguredComponent() {
|
|
16
|
+
return <div>unconfigured</div>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Use a unique tag name per test to avoid collisions
|
|
20
|
+
let tagCounter = 0;
|
|
21
|
+
function uniqueType() {
|
|
22
|
+
return `test-card-${++tagCounter}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeHass(states: Record<string, any>): HomeAssistant {
|
|
26
|
+
return {
|
|
27
|
+
states,
|
|
28
|
+
config: {} as any,
|
|
29
|
+
services: {} as any,
|
|
30
|
+
connection: {} as any,
|
|
31
|
+
callService: vi.fn(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('registerPreactCard', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
lastConfig = undefined;
|
|
38
|
+
renderCount = 0;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
document.body.innerHTML = '';
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('registers the custom element and HA card entry', () => {
|
|
46
|
+
const type = uniqueType();
|
|
47
|
+
registerPreactCard({
|
|
48
|
+
type,
|
|
49
|
+
name: 'Test Card',
|
|
50
|
+
description: 'A test card',
|
|
51
|
+
Component: TestComponent,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(customElements.get(type)).toBeDefined();
|
|
55
|
+
|
|
56
|
+
const win = window as any;
|
|
57
|
+
expect(win.customCards).toContainEqual({
|
|
58
|
+
type,
|
|
59
|
+
name: 'Test Card',
|
|
60
|
+
description: 'A test card',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders when both config and hass are set', () => {
|
|
65
|
+
const type = uniqueType();
|
|
66
|
+
registerPreactCard({
|
|
67
|
+
type,
|
|
68
|
+
name: 'Test',
|
|
69
|
+
description: 'Test',
|
|
70
|
+
Component: TestComponent,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const card = document.createElement(type) as any;
|
|
74
|
+
document.body.appendChild(card);
|
|
75
|
+
|
|
76
|
+
card.setConfig({ entities: ['sensor.temp'] });
|
|
77
|
+
expect(renderCount).toBe(0); // no hass yet
|
|
78
|
+
|
|
79
|
+
card.hass = makeHass({ 'sensor.temp': { state: '72' } });
|
|
80
|
+
expect(renderCount).toBe(1);
|
|
81
|
+
expect(lastConfig).toEqual({ entities: ['sensor.temp'] });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('re-renders when config changes after hass is set', () => {
|
|
85
|
+
const type = uniqueType();
|
|
86
|
+
registerPreactCard({
|
|
87
|
+
type,
|
|
88
|
+
name: 'Test',
|
|
89
|
+
description: 'Test',
|
|
90
|
+
Component: TestComponent,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const card = document.createElement(type) as any;
|
|
94
|
+
document.body.appendChild(card);
|
|
95
|
+
|
|
96
|
+
card.setConfig({ entities: ['sensor.temp'] });
|
|
97
|
+
card.hass = makeHass({ 'sensor.temp': { state: '72' } });
|
|
98
|
+
expect(renderCount).toBe(1);
|
|
99
|
+
|
|
100
|
+
card.setConfig({ entities: ['sensor.temp', 'sensor.humidity'] });
|
|
101
|
+
expect(renderCount).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('notifies entity subscribers when state changes', () => {
|
|
105
|
+
const type = uniqueType();
|
|
106
|
+
registerPreactCard({
|
|
107
|
+
type,
|
|
108
|
+
name: 'Test',
|
|
109
|
+
description: 'Test',
|
|
110
|
+
Component: TestComponent,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const card = document.createElement(type) as any;
|
|
114
|
+
document.body.appendChild(card);
|
|
115
|
+
|
|
116
|
+
card.setConfig({ entities: ['sensor.temp'] });
|
|
117
|
+
|
|
118
|
+
// Access the subscribe function via the internal API
|
|
119
|
+
const callback = vi.fn();
|
|
120
|
+
card['_subscribeToEntity']('sensor.temp', callback);
|
|
121
|
+
|
|
122
|
+
card.hass = makeHass({ 'sensor.temp': { state: '72' } });
|
|
123
|
+
expect(callback).toHaveBeenCalledWith({ state: '72' });
|
|
124
|
+
|
|
125
|
+
// Different state = notification
|
|
126
|
+
callback.mockClear();
|
|
127
|
+
card.hass = makeHass({ 'sensor.temp': { state: '73' } });
|
|
128
|
+
expect(callback).toHaveBeenCalledWith({ state: '73' });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('notifies for any subscribed entity (no _getEntityIds filter)', () => {
|
|
132
|
+
const type = uniqueType();
|
|
133
|
+
registerPreactCard({
|
|
134
|
+
type,
|
|
135
|
+
name: 'Test',
|
|
136
|
+
description: 'Test',
|
|
137
|
+
Component: TestComponent,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const card = document.createElement(type) as any;
|
|
141
|
+
document.body.appendChild(card);
|
|
142
|
+
|
|
143
|
+
card.setConfig({ entities: ['sensor.temp'] });
|
|
144
|
+
|
|
145
|
+
// Subscribe to an entity NOT in config - should still get notified
|
|
146
|
+
const callback = vi.fn();
|
|
147
|
+
card['_subscribeToEntity']('sensor.other', callback);
|
|
148
|
+
|
|
149
|
+
card.hass = makeHass({
|
|
150
|
+
'sensor.temp': { state: '72' },
|
|
151
|
+
'sensor.other': { state: 'on' },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(callback).toHaveBeenCalledWith({ state: 'on' });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('clears all listeners on disconnect', () => {
|
|
158
|
+
const type = uniqueType();
|
|
159
|
+
registerPreactCard({
|
|
160
|
+
type,
|
|
161
|
+
name: 'Test',
|
|
162
|
+
description: 'Test',
|
|
163
|
+
Component: TestComponent,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const card = document.createElement(type) as any;
|
|
167
|
+
document.body.appendChild(card);
|
|
168
|
+
|
|
169
|
+
card.setConfig({ entities: ['sensor.temp'] });
|
|
170
|
+
const callback = vi.fn();
|
|
171
|
+
card['_subscribeToEntity']('sensor.temp', callback);
|
|
172
|
+
|
|
173
|
+
card.hass = makeHass({ 'sensor.temp': { state: '72' } });
|
|
174
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
175
|
+
|
|
176
|
+
card.disconnectedCallback();
|
|
177
|
+
callback.mockClear();
|
|
178
|
+
|
|
179
|
+
card.hass = makeHass({ 'sensor.temp': { state: '73' } });
|
|
180
|
+
expect(callback).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('registers editor element when ConfigComponent is provided', () => {
|
|
184
|
+
const type = uniqueType();
|
|
185
|
+
function TestEditor() {
|
|
186
|
+
return <div>editor</div>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
registerPreactCard({
|
|
190
|
+
type,
|
|
191
|
+
name: 'Test',
|
|
192
|
+
description: 'Test',
|
|
193
|
+
Component: TestComponent,
|
|
194
|
+
ConfigComponent: TestEditor,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(customElements.get(`${type}-editor`)).toBeDefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('returns stub config from getStubConfig', () => {
|
|
201
|
+
const type = uniqueType();
|
|
202
|
+
registerPreactCard({
|
|
203
|
+
type,
|
|
204
|
+
name: 'Test',
|
|
205
|
+
description: 'Test',
|
|
206
|
+
Component: TestComponent,
|
|
207
|
+
getStubConfig: () => ({ entities: [] }),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const CardClass = customElements.get(type) as any;
|
|
211
|
+
expect(CardClass.getStubConfig()).toEqual({ entities: [] });
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Node 22+ has a built-in localStorage that conflicts with jsdom's.
|
|
2
|
+
// This setup ensures we have a proper Web Storage API implementation.
|
|
3
|
+
|
|
4
|
+
const store = new Map<string, string>();
|
|
5
|
+
|
|
6
|
+
const localStorageMock: Storage = {
|
|
7
|
+
getItem: (key: string) => store.get(key) ?? null,
|
|
8
|
+
setItem: (key: string, value: string) => {
|
|
9
|
+
store.set(key, value);
|
|
10
|
+
},
|
|
11
|
+
removeItem: (key: string) => {
|
|
12
|
+
store.delete(key);
|
|
13
|
+
},
|
|
14
|
+
clear: () => {
|
|
15
|
+
store.clear();
|
|
16
|
+
},
|
|
17
|
+
get length() {
|
|
18
|
+
return store.size;
|
|
19
|
+
},
|
|
20
|
+
key: (index: number) => [...store.keys()][index] ?? null,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
24
|
+
value: localStorageMock,
|
|
25
|
+
writable: true,
|
|
26
|
+
configurable: true,
|
|
27
|
+
});
|