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,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
|
+
});
|