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.
@@ -0,0 +1,73 @@
1
+ import { type RenderOptions, render } from '@testing-library/preact';
2
+ import type { ComponentChildren } from 'preact';
3
+ import { vi } from 'vitest';
4
+ import { HAProvider } from '../HAContext';
5
+ import type { HomeAssistant } from '../types';
6
+
7
+ export type MockSubscribeFn = (entityId: string, callback: (entity: any) => void) => () => void;
8
+
9
+ export function makeHass(
10
+ states: Record<string, any> = {},
11
+ overrides: Partial<HomeAssistant> = {},
12
+ ): HomeAssistant {
13
+ return {
14
+ states,
15
+ config: {} as any,
16
+ services: {} as any,
17
+ connection: {
18
+ sendMessagePromise: vi.fn(),
19
+ } as any,
20
+ callService: vi.fn(),
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Creates a mock subscribeToEntity that stores callbacks for manual triggering.
27
+ */
28
+ export function createMockSubscribe() {
29
+ const listeners = new Map<string, Set<(entity: any) => void>>();
30
+
31
+ const subscribe: MockSubscribeFn = (entityId, callback) => {
32
+ if (!listeners.has(entityId)) {
33
+ listeners.set(entityId, new Set());
34
+ }
35
+ listeners.get(entityId)!.add(callback);
36
+
37
+ return () => {
38
+ listeners.get(entityId)?.delete(callback);
39
+ };
40
+ };
41
+
42
+ const notify = (entityId: string, entity: any) => {
43
+ listeners.get(entityId)?.forEach((cb) => cb(entity));
44
+ };
45
+
46
+ return { subscribe, notify, listeners };
47
+ }
48
+
49
+ interface RenderWithHAOptions extends Omit<RenderOptions, 'wrapper'> {
50
+ hass?: HomeAssistant;
51
+ subscribeFn?: MockSubscribeFn;
52
+ }
53
+
54
+ export function renderWithHA(ui: ComponentChildren, options: RenderWithHAOptions = {}) {
55
+ const {
56
+ hass = makeHass(),
57
+ subscribeFn = createMockSubscribe().subscribe,
58
+ ...renderOptions
59
+ } = options;
60
+
61
+ function Wrapper({ children }: { children: ComponentChildren }) {
62
+ return (
63
+ <HAProvider hass={hass} subscribeToEntity={subscribeFn}>
64
+ {children}
65
+ </HAProvider>
66
+ );
67
+ }
68
+
69
+ return {
70
+ ...render(ui as any, { wrapper: Wrapper as any, ...renderOptions }),
71
+ hass,
72
+ };
73
+ }
@@ -0,0 +1,147 @@
1
+ import { act, screen, waitFor } from '@testing-library/preact';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { useCachedFetch } from '../HAContext';
4
+ import type { FetchStatus } from '../types';
5
+ import { createMockSubscribe, makeHass, renderWithHA } from './testHelpers';
6
+
7
+ let lastStatus: FetchStatus;
8
+
9
+ function FetchDisplay({
10
+ cacheKey,
11
+ fetcher,
12
+ deps,
13
+ }: {
14
+ cacheKey: string;
15
+ fetcher: () => Promise<any>;
16
+ deps: unknown[];
17
+ }) {
18
+ const { data, status, error } = useCachedFetch(cacheKey, fetcher, deps);
19
+ lastStatus = status;
20
+ return (
21
+ <div>
22
+ <span data-testid="data">{JSON.stringify(data) ?? 'undefined'}</span>
23
+ <span data-testid="status">{status}</span>
24
+ <span data-testid="error">{error?.message ?? 'none'}</span>
25
+ </div>
26
+ );
27
+ }
28
+
29
+ describe('useCachedFetch', () => {
30
+ beforeEach(() => {
31
+ localStorage.clear();
32
+ });
33
+
34
+ it('transitions from loading to ready on successful fetch', async () => {
35
+ const fetcher = vi.fn().mockResolvedValue({ temp: 72 });
36
+ const { subscribe } = createMockSubscribe();
37
+
38
+ renderWithHA(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={[]} />, {
39
+ subscribeFn: subscribe,
40
+ });
41
+
42
+ await waitFor(() => {
43
+ expect(screen.getByTestId('status').textContent).toBe('ready');
44
+ });
45
+
46
+ expect(screen.getByTestId('data').textContent).toBe('{"temp":72}');
47
+ });
48
+
49
+ it('shows cached data while refreshing', async () => {
50
+ // Pre-populate cache
51
+ const entry = {
52
+ data: { temp: 68 },
53
+ timestamp: Date.now(),
54
+ };
55
+ localStorage.setItem('preact-ha:test', JSON.stringify(entry));
56
+
57
+ let resolve: (v: any) => void;
58
+ const fetcher = vi.fn().mockImplementation(
59
+ () =>
60
+ new Promise((r) => {
61
+ resolve = r;
62
+ }),
63
+ );
64
+ const { subscribe } = createMockSubscribe();
65
+
66
+ renderWithHA(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={[]} />, {
67
+ subscribeFn: subscribe,
68
+ });
69
+
70
+ // Should show cached data
71
+ expect(screen.getByTestId('data').textContent).toBe('{"temp":68}');
72
+
73
+ // Resolve the fetch
74
+ await act(async () => {
75
+ resolve!({ temp: 75 });
76
+ });
77
+
78
+ await waitFor(() => {
79
+ expect(screen.getByTestId('status').textContent).toBe('ready');
80
+ });
81
+
82
+ expect(screen.getByTestId('data').textContent).toBe('{"temp":75}');
83
+ });
84
+
85
+ it('reports errors from failed fetches', async () => {
86
+ const fetcher = vi.fn().mockRejectedValue(new Error('Network error'));
87
+ const { subscribe } = createMockSubscribe();
88
+
89
+ renderWithHA(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={[]} />, {
90
+ subscribeFn: subscribe,
91
+ });
92
+
93
+ await waitFor(() => {
94
+ expect(screen.getByTestId('error').textContent).toBe('Network error');
95
+ });
96
+ });
97
+
98
+ it('saves fetched data to cache', async () => {
99
+ const fetcher = vi.fn().mockResolvedValue({ temp: 72 });
100
+ const { subscribe } = createMockSubscribe();
101
+
102
+ renderWithHA(<FetchDisplay cacheKey="cache-write-test" fetcher={fetcher} deps={[]} />, {
103
+ subscribeFn: subscribe,
104
+ });
105
+
106
+ await waitFor(() => {
107
+ expect(screen.getByTestId('status').textContent).toBe('ready');
108
+ });
109
+
110
+ const cached = localStorage.getItem('preact-ha:cache-write-test');
111
+ expect(cached).toBeTruthy();
112
+ expect(JSON.parse(cached!).data).toEqual({ temp: 72 });
113
+ });
114
+
115
+ it('ignores stale fetch results', async () => {
116
+ const resolvers: Array<(v: any) => void> = [];
117
+ const fetcher = vi.fn().mockImplementation(
118
+ () =>
119
+ new Promise((r) => {
120
+ resolvers.push(r);
121
+ }),
122
+ );
123
+ const { subscribe } = createMockSubscribe();
124
+
125
+ const { rerender } = renderWithHA(
126
+ <FetchDisplay cacheKey="test" fetcher={fetcher} deps={['a']} />,
127
+ { subscribeFn: subscribe },
128
+ );
129
+
130
+ // Trigger a second fetch by changing deps
131
+ rerender(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={['b']} />);
132
+
133
+ // Resolve the first (stale) fetch
134
+ await act(async () => {
135
+ resolvers[0]?.({ temp: 'stale' });
136
+ });
137
+
138
+ // Resolve the second (current) fetch
139
+ await act(async () => {
140
+ resolvers[1]?.({ temp: 'fresh' });
141
+ });
142
+
143
+ await waitFor(() => {
144
+ expect(screen.getByTestId('data').textContent).toBe('{"temp":"fresh"}');
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,140 @@
1
+ import { act, screen } from '@testing-library/preact';
2
+ import { useRef } from 'preact/hooks';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { useEntity } from '../HAContext';
5
+ import { createMockSubscribe, makeHass, renderWithHA } from './testHelpers';
6
+
7
+ function EntityDisplay({ entityId }: { entityId: string }) {
8
+ const entity = useEntity(entityId);
9
+ const renderCount = useRef(0);
10
+ renderCount.current++;
11
+ return (
12
+ <div>
13
+ <span data-testid="state">{entity?.state ?? 'undefined'}</span>
14
+ <span data-testid="id">{entity?.entity_id ?? 'none'}</span>
15
+ <span data-testid="renders">{renderCount.current}</span>
16
+ </div>
17
+ );
18
+ }
19
+
20
+ describe('useEntity', () => {
21
+ beforeEach(() => {
22
+ localStorage.clear();
23
+ });
24
+
25
+ it('returns the initial entity state from hass', () => {
26
+ const hass = makeHass({
27
+ 'sensor.temp': { entity_id: 'sensor.temp', state: '72' },
28
+ });
29
+ const { subscribe } = createMockSubscribe();
30
+
31
+ renderWithHA(<EntityDisplay entityId="sensor.temp" />, {
32
+ hass,
33
+ subscribeFn: subscribe,
34
+ });
35
+
36
+ expect(screen.getByTestId('state').textContent).toBe('72');
37
+ });
38
+
39
+ it('returns undefined for missing entities', () => {
40
+ const { subscribe } = createMockSubscribe();
41
+
42
+ renderWithHA(<EntityDisplay entityId="sensor.missing" />, {
43
+ hass: makeHass({}),
44
+ subscribeFn: subscribe,
45
+ });
46
+
47
+ expect(screen.getByTestId('state').textContent).toBe('undefined');
48
+ });
49
+
50
+ it('updates when subscribeToEntity fires', async () => {
51
+ const { subscribe, notify } = createMockSubscribe();
52
+
53
+ renderWithHA(<EntityDisplay entityId="sensor.temp" />, {
54
+ hass: makeHass({}),
55
+ subscribeFn: subscribe,
56
+ });
57
+
58
+ expect(screen.getByTestId('state').textContent).toBe('undefined');
59
+
60
+ await act(() => {
61
+ notify('sensor.temp', { entity_id: 'sensor.temp', state: '75' });
62
+ });
63
+
64
+ expect(screen.getByTestId('state').textContent).toBe('75');
65
+ });
66
+
67
+ it('caches entity to localStorage on update', async () => {
68
+ const { subscribe, notify } = createMockSubscribe();
69
+
70
+ renderWithHA(<EntityDisplay entityId="sensor.temp" />, {
71
+ hass: makeHass({}),
72
+ subscribeFn: subscribe,
73
+ });
74
+
75
+ await act(() => {
76
+ notify('sensor.temp', { entity_id: 'sensor.temp', state: '75' });
77
+ });
78
+
79
+ const cached = localStorage.getItem('preact-ha:entity:sensor.temp');
80
+ expect(cached).toBeTruthy();
81
+ const parsed = JSON.parse(cached!);
82
+ expect(parsed.data.state).toBe('75');
83
+ });
84
+
85
+ it('loads from cache when hass has no state', () => {
86
+ // Pre-populate cache
87
+ const entry = {
88
+ data: { entity_id: 'sensor.temp', state: '68' },
89
+ timestamp: Date.now(),
90
+ };
91
+ localStorage.setItem('preact-ha:entity:sensor.temp', JSON.stringify(entry));
92
+
93
+ const { subscribe } = createMockSubscribe();
94
+
95
+ renderWithHA(<EntityDisplay entityId="sensor.temp" />, {
96
+ hass: makeHass({}),
97
+ subscribeFn: subscribe,
98
+ });
99
+
100
+ expect(screen.getByTestId('state').textContent).toBe('68');
101
+ });
102
+
103
+ it('does not re-render when a different entity changes', async () => {
104
+ const { subscribe, notify } = createMockSubscribe();
105
+
106
+ renderWithHA(<EntityDisplay entityId="sensor.temp" />, {
107
+ hass: makeHass({
108
+ 'sensor.temp': { entity_id: 'sensor.temp', state: '72' },
109
+ }),
110
+ subscribeFn: subscribe,
111
+ });
112
+
113
+ const rendersAfterMount = Number(screen.getByTestId('renders').textContent);
114
+
115
+ // Notify a completely different entity
116
+ await act(() => {
117
+ notify('sensor.humidity', { entity_id: 'sensor.humidity', state: '45' });
118
+ });
119
+
120
+ // Render count should not have increased
121
+ expect(Number(screen.getByTestId('renders').textContent)).toBe(rendersAfterMount);
122
+ // Original state unchanged
123
+ expect(screen.getByTestId('state').textContent).toBe('72');
124
+ });
125
+
126
+ it('unsubscribes on unmount', () => {
127
+ const { subscribe, listeners } = createMockSubscribe();
128
+
129
+ const { unmount } = renderWithHA(<EntityDisplay entityId="sensor.temp" />, {
130
+ hass: makeHass({}),
131
+ subscribeFn: subscribe,
132
+ });
133
+
134
+ expect(listeners.get('sensor.temp')?.size).toBe(1);
135
+
136
+ unmount();
137
+
138
+ expect(listeners.get('sensor.temp')?.size ?? 0).toBe(0);
139
+ });
140
+ });
@@ -0,0 +1,217 @@
1
+ import { act, screen, waitFor } from '@testing-library/preact';
2
+ import { render } from '@testing-library/preact';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { useMultiCalendarEvents } from '../HAContext';
5
+ import { HAProvider } from '../HAContext';
6
+ import type { FetchStatus } from '../types';
7
+ import type { CalendarEventWithSource } from '../types';
8
+ import { createMockSubscribe, makeHass } from './testHelpers';
9
+
10
+ function CalendarDisplay({
11
+ entityIds,
12
+ start,
13
+ end,
14
+ }: {
15
+ entityIds: `calendar.${string}`[];
16
+ start: Date;
17
+ end: Date;
18
+ }) {
19
+ const { events, status, error } = useMultiCalendarEvents(entityIds, {
20
+ start,
21
+ end,
22
+ });
23
+ return (
24
+ <div>
25
+ <span data-testid="count">{events?.length ?? 'loading'}</span>
26
+ <span data-testid="status">{status}</span>
27
+ <span data-testid="error">{error?.message ?? 'none'}</span>
28
+ <span data-testid="events">
29
+ {events?.map((e: CalendarEventWithSource) => `${e.calendarId}:${e.summary}`).join(',') ??
30
+ ''}
31
+ </span>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ describe('useMultiCalendarEvents', () => {
37
+ const start = new Date('2025-01-01');
38
+ const end = new Date('2025-01-31');
39
+
40
+ beforeEach(() => {
41
+ localStorage.clear();
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ it('fetches events from multiple calendars', async () => {
46
+ const sendMessagePromise = vi
47
+ .fn()
48
+ .mockResolvedValueOnce({
49
+ response: {
50
+ 'calendar.family': {
51
+ events: [{ start: '2025-01-05', end: '2025-01-05', summary: 'Birthday' }],
52
+ },
53
+ },
54
+ })
55
+ .mockResolvedValueOnce({
56
+ response: {
57
+ 'calendar.work': {
58
+ events: [{ start: '2025-01-10', end: '2025-01-10', summary: 'Meeting' }],
59
+ },
60
+ },
61
+ });
62
+
63
+ const hass = makeHass(
64
+ {},
65
+ {
66
+ connection: { sendMessagePromise } as any,
67
+ },
68
+ );
69
+ const { subscribe } = createMockSubscribe();
70
+
71
+ render(
72
+ <HAProvider hass={hass} subscribeToEntity={subscribe}>
73
+ <CalendarDisplay entityIds={['calendar.family', 'calendar.work']} start={start} end={end} />
74
+ </HAProvider>,
75
+ );
76
+
77
+ await waitFor(() => {
78
+ expect(screen.getByTestId('status').textContent).toBe('ready');
79
+ });
80
+
81
+ expect(screen.getByTestId('count').textContent).toBe('2');
82
+ expect(screen.getByTestId('events').textContent).toContain('calendar.family:Birthday');
83
+ expect(screen.getByTestId('events').textContent).toContain('calendar.work:Meeting');
84
+ });
85
+
86
+ it('attaches calendarId to each event', async () => {
87
+ const sendMessagePromise = vi.fn().mockResolvedValue({
88
+ response: {
89
+ 'calendar.family': {
90
+ events: [{ start: '2025-01-05', end: '2025-01-05', summary: 'Event' }],
91
+ },
92
+ },
93
+ });
94
+
95
+ const hass = makeHass(
96
+ {},
97
+ {
98
+ connection: { sendMessagePromise } as any,
99
+ },
100
+ );
101
+ const { subscribe } = createMockSubscribe();
102
+
103
+ render(
104
+ <HAProvider hass={hass} subscribeToEntity={subscribe}>
105
+ <CalendarDisplay entityIds={['calendar.family']} start={start} end={end} />
106
+ </HAProvider>,
107
+ );
108
+
109
+ await waitFor(() => {
110
+ expect(screen.getByTestId('events').textContent).toBe('calendar.family:Event');
111
+ });
112
+ });
113
+
114
+ it('handles fetch failure for one calendar gracefully', async () => {
115
+ const sendMessagePromise = vi
116
+ .fn()
117
+ .mockRejectedValueOnce(new Error('Calendar offline'))
118
+ .mockResolvedValueOnce({
119
+ response: {
120
+ 'calendar.work': {
121
+ events: [{ start: '2025-01-10', end: '2025-01-10', summary: 'Meeting' }],
122
+ },
123
+ },
124
+ });
125
+
126
+ // Suppress the expected console.error
127
+ vi.spyOn(console, 'error').mockImplementation(() => {});
128
+
129
+ const hass = makeHass(
130
+ {},
131
+ {
132
+ connection: { sendMessagePromise } as any,
133
+ },
134
+ );
135
+ const { subscribe } = createMockSubscribe();
136
+
137
+ render(
138
+ <HAProvider hass={hass} subscribeToEntity={subscribe}>
139
+ <CalendarDisplay entityIds={['calendar.family', 'calendar.work']} start={start} end={end} />
140
+ </HAProvider>,
141
+ );
142
+
143
+ await waitFor(() => {
144
+ expect(screen.getByTestId('status').textContent).toBe('ready');
145
+ });
146
+
147
+ // Should still get events from the working calendar
148
+ expect(screen.getByTestId('count').textContent).toBe('1');
149
+ expect(screen.getByTestId('events').textContent).toBe('calendar.work:Meeting');
150
+ });
151
+
152
+ it('returns empty array for empty entityIds', async () => {
153
+ const { subscribe } = createMockSubscribe();
154
+
155
+ render(
156
+ <HAProvider hass={makeHass()} subscribeToEntity={subscribe}>
157
+ <CalendarDisplay entityIds={[]} start={start} end={end} />
158
+ </HAProvider>,
159
+ );
160
+
161
+ await waitFor(() => {
162
+ expect(screen.getByTestId('status').textContent).toBe('ready');
163
+ });
164
+
165
+ expect(screen.getByTestId('count').textContent).toBe('0');
166
+ });
167
+
168
+ it('refetches when entity changes (debounced)', async () => {
169
+ vi.useFakeTimers();
170
+
171
+ const sendMessagePromise = vi.fn().mockResolvedValue({
172
+ response: {
173
+ 'calendar.family': {
174
+ events: [{ start: '2025-01-05', end: '2025-01-05', summary: 'Event' }],
175
+ },
176
+ },
177
+ });
178
+
179
+ const hass = makeHass(
180
+ {},
181
+ {
182
+ connection: { sendMessagePromise } as any,
183
+ },
184
+ );
185
+ const { subscribe, notify } = createMockSubscribe();
186
+
187
+ render(
188
+ <HAProvider hass={hass} subscribeToEntity={subscribe}>
189
+ <CalendarDisplay entityIds={['calendar.family']} start={start} end={end} />
190
+ </HAProvider>,
191
+ );
192
+
193
+ // Wait for initial fetch
194
+ await act(async () => {
195
+ await vi.advanceTimersByTimeAsync(100);
196
+ });
197
+
198
+ const initialCallCount = sendMessagePromise.mock.calls.length;
199
+
200
+ // Simulate entity change
201
+ act(() => {
202
+ notify('calendar.family', { state: 'on' });
203
+ });
204
+
205
+ // Not called yet (debounced)
206
+ expect(sendMessagePromise.mock.calls.length).toBe(initialCallCount);
207
+
208
+ // Advance past debounce
209
+ await act(async () => {
210
+ await vi.advanceTimersByTimeAsync(600);
211
+ });
212
+
213
+ expect(sendMessagePromise.mock.calls.length).toBeGreaterThan(initialCallCount);
214
+
215
+ vi.useRealTimers();
216
+ });
217
+ });