volto-aemet 1.0.0-alpha.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/.changelog.draft +3 -0
- package/.release-it.json +34 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +323 -0
- package/babel.config.js +17 -0
- package/locales/de/LC_MESSAGES/volto.po +83 -0
- package/locales/en/LC_MESSAGES/volto.po +83 -0
- package/locales/es/LC_MESSAGES/volto.po +90 -0
- package/locales/pt_BR/LC_MESSAGES/volto.po +88 -0
- package/locales/volto.pot +85 -0
- package/news/.gitkeep +0 -0
- package/package.json +47 -0
- package/public/.gitkeep +0 -0
- package/src/components/Aemet/Weather.js +289 -0
- package/src/components/Aemet/Weather.test.js +398 -0
- package/src/components/Aemet/WeatherCurrent.jsx +97 -0
- package/src/components/Aemet/WeatherCurrent.test.jsx +282 -0
- package/src/components/Aemet/index.js +9 -0
- package/src/components/Blocks/AemetWeatherCurrent/View.jsx +11 -0
- package/src/components/Blocks/AemetWeatherForecast/Edit.jsx +72 -0
- package/src/components/Blocks/AemetWeatherForecast/View.jsx +32 -0
- package/src/components/Blocks/AemetWeatherForecast/messages.js +36 -0
- package/src/components/Blocks/AemetWeatherForecast/schema.js +37 -0
- package/src/config/blocks.ts +70 -0
- package/src/config/settings.ts +16 -0
- package/src/icons/aemet-logo.svg +311 -0
- package/src/icons/weather/temps/11.png +0 -0
- package/src/icons/weather/temps/11n.png +0 -0
- package/src/icons/weather/temps/12.png +0 -0
- package/src/icons/weather/temps/12n.png +0 -0
- package/src/icons/weather/temps/13.png +0 -0
- package/src/icons/weather/temps/13n.png +0 -0
- package/src/icons/weather/temps/14.png +0 -0
- package/src/icons/weather/temps/14n.png +0 -0
- package/src/icons/weather/temps/15.png +0 -0
- package/src/icons/weather/temps/15n.png +0 -0
- package/src/icons/weather/temps/16.png +0 -0
- package/src/icons/weather/temps/16n.png +0 -0
- package/src/icons/weather/temps/17.png +0 -0
- package/src/icons/weather/temps/17n.png +0 -0
- package/src/icons/weather/temps/23.png +0 -0
- package/src/icons/weather/temps/23n.png +0 -0
- package/src/icons/weather/temps/24.png +0 -0
- package/src/icons/weather/temps/24n.png +0 -0
- package/src/icons/weather/temps/25.png +0 -0
- package/src/icons/weather/temps/25n.png +0 -0
- package/src/icons/weather/temps/26.png +0 -0
- package/src/icons/weather/temps/26n.png +0 -0
- package/src/icons/weather/temps/33.png +0 -0
- package/src/icons/weather/temps/33n.png +0 -0
- package/src/icons/weather/temps/34.png +0 -0
- package/src/icons/weather/temps/34n.png +0 -0
- package/src/icons/weather/temps/35.png +0 -0
- package/src/icons/weather/temps/35n.png +0 -0
- package/src/icons/weather/temps/36.png +0 -0
- package/src/icons/weather/temps/36n.png +0 -0
- package/src/icons/weather/temps/43.png +0 -0
- package/src/icons/weather/temps/43n.png +0 -0
- package/src/icons/weather/temps/44.png +0 -0
- package/src/icons/weather/temps/44n.png +0 -0
- package/src/icons/weather/temps/45.png +0 -0
- package/src/icons/weather/temps/45n.png +0 -0
- package/src/icons/weather/temps/46.png +0 -0
- package/src/icons/weather/temps/46n.png +0 -0
- package/src/icons/weather/temps/51.png +0 -0
- package/src/icons/weather/temps/51n.png +0 -0
- package/src/icons/weather/temps/52.png +0 -0
- package/src/icons/weather/temps/52n.png +0 -0
- package/src/icons/weather/temps/53.png +0 -0
- package/src/icons/weather/temps/54.png +0 -0
- package/src/icons/weather/temps/54n.png +0 -0
- package/src/icons/weather/temps/61.png +0 -0
- package/src/icons/weather/temps/61n.png +0 -0
- package/src/icons/weather/temps/62.png +0 -0
- package/src/icons/weather/temps/62n.png +0 -0
- package/src/icons/weather/temps/63.png +0 -0
- package/src/icons/weather/temps/63n.png +0 -0
- package/src/icons/weather/temps/64.png +0 -0
- package/src/icons/weather/temps/64n.png +0 -0
- package/src/icons/weather/temps/71.png +0 -0
- package/src/icons/weather/temps/71n.png +0 -0
- package/src/icons/weather/temps/72.png +0 -0
- package/src/icons/weather/temps/72n.png +0 -0
- package/src/icons/weather/temps/73.png +0 -0
- package/src/icons/weather/temps/74.png +0 -0
- package/src/index.ts +24 -0
- package/src/theme/components/_weather_current.scss +69 -0
- package/src/theme/main.scss +3 -0
- package/towncrier.toml +33 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Weather.js shared logic.
|
|
3
|
+
* @module components/Aemet/Weather.test
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
8
|
+
import { IntlProvider } from 'react-intl';
|
|
9
|
+
import moment from 'moment';
|
|
10
|
+
|
|
11
|
+
import { getWeatherIcon, weatherMessages, useWeatherData } from './Weather';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Wrap a hook under test with an IntlProvider (required by useIntl). */
|
|
18
|
+
const wrapper = ({ children }) => (
|
|
19
|
+
<IntlProvider locale="en" messages={{}}>
|
|
20
|
+
{children}
|
|
21
|
+
</IntlProvider>
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// weatherMessages
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
describe('weatherMessages', () => {
|
|
29
|
+
it('exports all required message keys', () => {
|
|
30
|
+
const requiredKeys = [
|
|
31
|
+
'minimumMaximum',
|
|
32
|
+
'currentDay',
|
|
33
|
+
'currentLocation',
|
|
34
|
+
'currentTemperature',
|
|
35
|
+
'currentTime',
|
|
36
|
+
'loadingCurrent',
|
|
37
|
+
'weatherNotAvailable',
|
|
38
|
+
'weatherDescription',
|
|
39
|
+
];
|
|
40
|
+
requiredKeys.forEach((key) => {
|
|
41
|
+
expect(weatherMessages).toHaveProperty(key);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('every message has a non-empty string id and defaultMessage', () => {
|
|
46
|
+
Object.values(weatherMessages).forEach((msg) => {
|
|
47
|
+
expect(typeof msg.id).toBe('string');
|
|
48
|
+
expect(msg.id.length).toBeGreaterThan(0);
|
|
49
|
+
expect(typeof msg.defaultMessage).toBe('string');
|
|
50
|
+
expect(msg.defaultMessage.length).toBeGreaterThan(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// getWeatherIcon
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
describe('getWeatherIcon', () => {
|
|
60
|
+
it('returns a defined value for all known day codes', () => {
|
|
61
|
+
const dayCodes = [
|
|
62
|
+
'11',
|
|
63
|
+
'12',
|
|
64
|
+
'13',
|
|
65
|
+
'14',
|
|
66
|
+
'15',
|
|
67
|
+
'16',
|
|
68
|
+
'17',
|
|
69
|
+
'23',
|
|
70
|
+
'24',
|
|
71
|
+
'25',
|
|
72
|
+
'26',
|
|
73
|
+
'33',
|
|
74
|
+
'34',
|
|
75
|
+
'35',
|
|
76
|
+
'36',
|
|
77
|
+
'43',
|
|
78
|
+
'44',
|
|
79
|
+
'45',
|
|
80
|
+
'46',
|
|
81
|
+
'51',
|
|
82
|
+
'52',
|
|
83
|
+
'53',
|
|
84
|
+
'54',
|
|
85
|
+
'61',
|
|
86
|
+
'62',
|
|
87
|
+
'63',
|
|
88
|
+
'64',
|
|
89
|
+
'71',
|
|
90
|
+
'72',
|
|
91
|
+
'73',
|
|
92
|
+
'74',
|
|
93
|
+
];
|
|
94
|
+
dayCodes.forEach((code) => {
|
|
95
|
+
const icon = getWeatherIcon(code);
|
|
96
|
+
expect(icon).toBeDefined();
|
|
97
|
+
expect(icon).not.toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns a defined value for known night codes', () => {
|
|
102
|
+
const nightCodes = [
|
|
103
|
+
'11n',
|
|
104
|
+
'12n',
|
|
105
|
+
'13n',
|
|
106
|
+
'14n',
|
|
107
|
+
'15n',
|
|
108
|
+
'16n',
|
|
109
|
+
'17n',
|
|
110
|
+
'23n',
|
|
111
|
+
'24n',
|
|
112
|
+
'25n',
|
|
113
|
+
'26n',
|
|
114
|
+
'33n',
|
|
115
|
+
'34n',
|
|
116
|
+
'35n',
|
|
117
|
+
'36n',
|
|
118
|
+
'43n',
|
|
119
|
+
'44n',
|
|
120
|
+
'45n',
|
|
121
|
+
'46n',
|
|
122
|
+
'51n',
|
|
123
|
+
'52n',
|
|
124
|
+
'54n',
|
|
125
|
+
'61n',
|
|
126
|
+
'62n',
|
|
127
|
+
'63n',
|
|
128
|
+
'64n',
|
|
129
|
+
'71n',
|
|
130
|
+
'72n',
|
|
131
|
+
];
|
|
132
|
+
nightCodes.forEach((code) => {
|
|
133
|
+
const icon = getWeatherIcon(code);
|
|
134
|
+
expect(icon).toBeDefined();
|
|
135
|
+
expect(icon).not.toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns different icons for day and night when both exist', () => {
|
|
140
|
+
expect(getWeatherIcon('11')).not.toBe(getWeatherIcon('11n'));
|
|
141
|
+
expect(getWeatherIcon('46')).not.toBe(getWeatherIcon('46n'));
|
|
142
|
+
expect(getWeatherIcon('61')).not.toBe(getWeatherIcon('61n'));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns the same icon for 53 and 53n (no dedicated night icon)', () => {
|
|
146
|
+
expect(getWeatherIcon('53n')).toBe(getWeatherIcon('53'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns the same icon for 73 and 73n', () => {
|
|
150
|
+
expect(getWeatherIcon('73n')).toBe(getWeatherIcon('73'));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns the same icon for 74 and 74n', () => {
|
|
154
|
+
expect(getWeatherIcon('74n')).toBe(getWeatherIcon('74'));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('trims surrounding whitespace from the input value', () => {
|
|
158
|
+
expect(getWeatherIcon(' 46 ')).toBe(getWeatherIcon('46'));
|
|
159
|
+
expect(getWeatherIcon(' 46n ')).toBe(getWeatherIcon('46n'));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('falls back to the day icon when only the night suffix is unknown', () => {
|
|
163
|
+
// '11' is valid; pretend there is a code like '11X' whose day code exists
|
|
164
|
+
// More directly: an uppercase N should not trip up the regex
|
|
165
|
+
expect(getWeatherIcon('11N')).toBe(getWeatherIcon('11'));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns the default icon (11) for an unknown sky-state code', () => {
|
|
169
|
+
const defaultIcon = getWeatherIcon('11');
|
|
170
|
+
expect(getWeatherIcon('999')).toBe(defaultIcon);
|
|
171
|
+
expect(getWeatherIcon('abc')).toBe(defaultIcon);
|
|
172
|
+
expect(getWeatherIcon('0')).toBe(defaultIcon);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('returns the default icon for null input', () => {
|
|
176
|
+
expect(getWeatherIcon(null)).toBe(getWeatherIcon('11'));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('returns the default icon for undefined input', () => {
|
|
180
|
+
expect(getWeatherIcon(undefined)).toBe(getWeatherIcon('11'));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('returns the default icon for an empty string', () => {
|
|
184
|
+
expect(getWeatherIcon('')).toBe(getWeatherIcon('11'));
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// useWeatherData
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe('useWeatherData', () => {
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
global.fetch = jest.fn();
|
|
195
|
+
// Suppress expected console.error calls from error-path tests
|
|
196
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
jest.restoreAllMocks();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('starts with isLoading=true and an empty forecast array', () => {
|
|
204
|
+
global.fetch.mockImplementation(() => new Promise(() => {})); // never resolves
|
|
205
|
+
const { result } = renderHook(() => useWeatherData(), { wrapper });
|
|
206
|
+
expect(result.current.isLoading).toBe(true);
|
|
207
|
+
expect(result.current.forecast).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('populates forecast with today data on a successful fetch', async () => {
|
|
211
|
+
const today = moment().format('YYYY-MM-DD');
|
|
212
|
+
global.fetch.mockResolvedValueOnce({
|
|
213
|
+
ok: true,
|
|
214
|
+
json: async () => ({
|
|
215
|
+
forecast: [
|
|
216
|
+
{
|
|
217
|
+
currentHour: '14',
|
|
218
|
+
date: today,
|
|
219
|
+
name: 'Fuenlabrada',
|
|
220
|
+
province: 'Madrid',
|
|
221
|
+
skyState: 'Partly cloudy',
|
|
222
|
+
skyStateValue: '46',
|
|
223
|
+
tempMin: '19',
|
|
224
|
+
tempMax: '36',
|
|
225
|
+
timePeriod: '12-18',
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const { result } = renderHook(() => useWeatherData(), { wrapper });
|
|
232
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
233
|
+
|
|
234
|
+
expect(result.current.forecast).toHaveLength(1);
|
|
235
|
+
const [day] = result.current.forecast;
|
|
236
|
+
expect(day.currentHour).toBe('14');
|
|
237
|
+
expect(day.date).toBe(today);
|
|
238
|
+
expect(day.name).toBe('Fuenlabrada');
|
|
239
|
+
expect(day.provinceName).toBe('Madrid');
|
|
240
|
+
expect(day.skyStateDescription).toBe('Partly cloudy');
|
|
241
|
+
expect(day.skyStateText).toBe('46');
|
|
242
|
+
expect(day.minTemp).toBe('19');
|
|
243
|
+
expect(day.maxTemp).toBe('36');
|
|
244
|
+
expect(day.timePeriod).toBe('12-18');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('calls the correct API endpoint with the proper headers', async () => {
|
|
248
|
+
const today = moment().format('YYYY-MM-DD');
|
|
249
|
+
global.fetch.mockResolvedValueOnce({
|
|
250
|
+
ok: true,
|
|
251
|
+
json: async () => ({
|
|
252
|
+
forecast: [
|
|
253
|
+
{
|
|
254
|
+
currentHour: '14',
|
|
255
|
+
date: today,
|
|
256
|
+
name: 'Fuenlabrada',
|
|
257
|
+
province: 'Madrid',
|
|
258
|
+
skyState: 'Sunny',
|
|
259
|
+
skyStateValue: '11',
|
|
260
|
+
tempMin: '20',
|
|
261
|
+
tempMax: '38',
|
|
262
|
+
timePeriod: '12-18',
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
renderHook(() => useWeatherData(), { wrapper });
|
|
269
|
+
|
|
270
|
+
await waitFor(() =>
|
|
271
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
272
|
+
'http://localhost:8080/Plone/++api++/@aemet-weather-forecast',
|
|
273
|
+
expect.objectContaining({
|
|
274
|
+
headers: expect.objectContaining({
|
|
275
|
+
Accept: 'application/json',
|
|
276
|
+
'Content-Type': 'application/json',
|
|
277
|
+
}),
|
|
278
|
+
}),
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('sets isLoading to false after a successful fetch', async () => {
|
|
284
|
+
const today = moment().format('YYYY-MM-DD');
|
|
285
|
+
global.fetch.mockResolvedValueOnce({
|
|
286
|
+
ok: true,
|
|
287
|
+
json: async () => ({
|
|
288
|
+
forecast: [
|
|
289
|
+
{
|
|
290
|
+
currentHour: '14',
|
|
291
|
+
date: today,
|
|
292
|
+
name: 'Sevilla',
|
|
293
|
+
province: 'Sevilla',
|
|
294
|
+
skyState: 'Clear',
|
|
295
|
+
skyStateValue: '11',
|
|
296
|
+
tempMin: '18',
|
|
297
|
+
tempMax: '33',
|
|
298
|
+
timePeriod: '12-18',
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
}),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const { result } = renderHook(() => useWeatherData(), { wrapper });
|
|
305
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
306
|
+
expect(result.current.isLoading).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('uses fallback data when the HTTP response is not ok', async () => {
|
|
310
|
+
global.fetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
311
|
+
|
|
312
|
+
const { result } = renderHook(() => useWeatherData(), { wrapper });
|
|
313
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
314
|
+
|
|
315
|
+
const [fallback] = result.current.forecast;
|
|
316
|
+
expect(fallback.name).toBe('Sevilla');
|
|
317
|
+
expect(fallback.provinceName).toBe('Sevilla');
|
|
318
|
+
expect(fallback.minTemp).toBe('--');
|
|
319
|
+
expect(fallback.maxTemp).toBe('--');
|
|
320
|
+
expect(fallback.skyStateText).toBe('11');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('uses fallback data when fetch throws a network error', async () => {
|
|
324
|
+
global.fetch.mockRejectedValueOnce(new Error('Network failure'));
|
|
325
|
+
|
|
326
|
+
const { result } = renderHook(() => useWeatherData(), { wrapper });
|
|
327
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
328
|
+
|
|
329
|
+
expect(result.current.forecast[0].name).toBe('Sevilla');
|
|
330
|
+
expect(result.current.forecast[0].provinceName).toBe('Sevilla');
|
|
331
|
+
expect(result.current.forecast[0].maxTemp).toBe('--');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('uses fallback data when the API response has no forecast key', async () => {
|
|
335
|
+
global.fetch.mockResolvedValueOnce({
|
|
336
|
+
ok: true,
|
|
337
|
+
json: async () => ({}),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const { result } = renderHook(() => useWeatherData(), { wrapper });
|
|
341
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
342
|
+
|
|
343
|
+
expect(result.current.forecast[0].name).toBe('Sevilla');
|
|
344
|
+
expect(result.current.forecast[0].provinceName).toBe('Sevilla');
|
|
345
|
+
expect(result.current.forecast[0].maxTemp).toBe('--');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('uses fallback data when forecast array is empty', async () => {
|
|
349
|
+
global.fetch.mockResolvedValueOnce({
|
|
350
|
+
ok: true,
|
|
351
|
+
json: async () => ({ forecast: [] }),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const { result } = renderHook(() => useWeatherData(), { wrapper });
|
|
355
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
356
|
+
|
|
357
|
+
expect(result.current.forecast[0].name).toBe('Sevilla');
|
|
358
|
+
expect(result.current.forecast[0].provinceName).toBe('Sevilla');
|
|
359
|
+
expect(result.current.forecast[0].maxTemp).toBe('--');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('uses fallback data when today is not in the forecast list', async () => {
|
|
363
|
+
global.fetch.mockResolvedValueOnce({
|
|
364
|
+
ok: true,
|
|
365
|
+
json: async () => ({
|
|
366
|
+
forecast: [
|
|
367
|
+
{
|
|
368
|
+
currentHour: '14',
|
|
369
|
+
date: '2000-01-01', // deliberately not today
|
|
370
|
+
name: 'Barcelona',
|
|
371
|
+
province: 'Barcelona',
|
|
372
|
+
skyState: 'Clear',
|
|
373
|
+
skyStateValue: '11',
|
|
374
|
+
tempMin: '10',
|
|
375
|
+
tempMax: '20',
|
|
376
|
+
timePeriod: '12-18',
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
}),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const { result } = renderHook(() => useWeatherData(), { wrapper });
|
|
383
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
384
|
+
|
|
385
|
+
expect(result.current.forecast[0].name).toBe('Sevilla');
|
|
386
|
+
expect(result.current.forecast[0].provinceName).toBe('Sevilla');
|
|
387
|
+
expect(result.current.forecast[0].maxTemp).toBe('--');
|
|
388
|
+
expect(result.current.forecast[0].minTemp).toBe('--');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('sets isLoading to false even when the fetch fails', async () => {
|
|
392
|
+
global.fetch.mockRejectedValueOnce(new Error('Timeout'));
|
|
393
|
+
|
|
394
|
+
const { result } = renderHook(() => useWeatherData(), { wrapper });
|
|
395
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
396
|
+
expect(result.current.isLoading).toBe(false);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeatherCurrent component — card widget with time, day, city and temperature.
|
|
3
|
+
* @module components/Aemet/WeatherCurrent
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState, useEffect } from 'react';
|
|
7
|
+
import { FormattedMessage, useIntl } from 'react-intl';
|
|
8
|
+
import moment from 'moment';
|
|
9
|
+
import Image from '@plone/volto/components/theme/Image/Image';
|
|
10
|
+
import { weatherMessages, getWeatherIcon, useWeatherData } from './Weather.js'; // explicit .js — shared logic, not the view
|
|
11
|
+
|
|
12
|
+
const WeatherCurrent = (props) => {
|
|
13
|
+
const { forecast, isLoading } = useWeatherData();
|
|
14
|
+
const intl = useIntl();
|
|
15
|
+
const [currentTime, setCurrentTime] = useState(moment());
|
|
16
|
+
|
|
17
|
+
// Update clock every minute
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const timer = setInterval(() => {
|
|
20
|
+
setCurrentTime(moment());
|
|
21
|
+
}, 60000);
|
|
22
|
+
|
|
23
|
+
return () => clearInterval(timer);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="weather-current-wrapper">
|
|
28
|
+
{isLoading || forecast.length === 0 ? (
|
|
29
|
+
<p>
|
|
30
|
+
🌡️{' '}
|
|
31
|
+
<FormattedMessage
|
|
32
|
+
id="loading_weather_forecast"
|
|
33
|
+
defaultMessage="Loading weather forecast..."
|
|
34
|
+
/>
|
|
35
|
+
</p>
|
|
36
|
+
) : (
|
|
37
|
+
forecast.slice(0, 1).map((day, i) => (
|
|
38
|
+
<div key={i} className="weather-current-card">
|
|
39
|
+
<div className="weather-current-time-row">
|
|
40
|
+
<span
|
|
41
|
+
className="weather-current-time"
|
|
42
|
+
title={intl.formatMessage(weatherMessages.currentTime)}
|
|
43
|
+
>
|
|
44
|
+
{currentTime.format('HH:mm')}
|
|
45
|
+
</span>
|
|
46
|
+
<span
|
|
47
|
+
className="weather-current-day"
|
|
48
|
+
title={intl.formatMessage(weatherMessages.currentDay)}
|
|
49
|
+
>
|
|
50
|
+
{currentTime.format('ddd').toUpperCase()}
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
<div
|
|
54
|
+
className="weather-current-city"
|
|
55
|
+
title={intl.formatMessage(weatherMessages.currentLocation)}
|
|
56
|
+
>
|
|
57
|
+
{`${day.name}, ${day.provinceName}` || 'Sevilla, Spain.'}
|
|
58
|
+
</div>
|
|
59
|
+
<div className="weather-current-bottom">
|
|
60
|
+
<span
|
|
61
|
+
className="weather-current-temp"
|
|
62
|
+
title={intl.formatMessage(weatherMessages.minimumMaximum)}
|
|
63
|
+
>
|
|
64
|
+
{day.minTemp}-{day.maxTemp}º
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="weather-current-bottom">
|
|
68
|
+
<span
|
|
69
|
+
className="weather-current-temp"
|
|
70
|
+
title={intl.formatMessage(weatherMessages.currentTemperature)}
|
|
71
|
+
>
|
|
72
|
+
{day.maxTemp}º
|
|
73
|
+
</span>
|
|
74
|
+
<div
|
|
75
|
+
className="weather-current-icon"
|
|
76
|
+
title={
|
|
77
|
+
day.skyStateDescription ||
|
|
78
|
+
intl.formatMessage(weatherMessages.weatherDescription)
|
|
79
|
+
}
|
|
80
|
+
>
|
|
81
|
+
<Image
|
|
82
|
+
src={getWeatherIcon(day.skyStateText)}
|
|
83
|
+
alt={
|
|
84
|
+
day.skyStateDescription ||
|
|
85
|
+
intl.formatMessage(weatherMessages.weatherDescription)
|
|
86
|
+
}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
))
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export default WeatherCurrent;
|