diff --git a/defaultmodules/weather/providers/openweathermap.js b/defaultmodules/weather/providers/openweathermap.js index 995855f17e..a60ade013e 100644 --- a/defaultmodules/weather/providers/openweathermap.js +++ b/defaultmodules/weather/providers/openweathermap.js @@ -96,28 +96,41 @@ class OpenWeatherMapProvider { #handleResponse (data) { try { - // Set location name from timezone - if (data.timezone) { - this.locationName = data.timezone; - } - let weatherData; - const onecallData = this.#generateWeatherObjectsFromOnecall(data); - - switch (this.config.type) { - case "current": - weatherData = onecallData.current; - break; - case "forecast": - case "daily": - weatherData = onecallData.days; - break; - case "hourly": - weatherData = onecallData.hours; - break; - default: - Log.error(`[openweathermap] Unknown type: ${this.config.type}`); - throw new Error(`Unknown weather type: ${this.config.type}`); + + if (this.config.weatherEndpoint === "/onecall") { + // One Call API (v3.0) + if (data.timezone) { + this.locationName = data.timezone; + } + + const onecallData = this.#generateWeatherObjectsFromOnecall(data); + + switch (this.config.type) { + case "current": + weatherData = onecallData.current; + break; + case "forecast": + case "daily": + weatherData = onecallData.days; + break; + case "hourly": + weatherData = onecallData.hours; + break; + default: + Log.error(`[openweathermap] Unknown type: ${this.config.type}`); + throw new Error(`Unknown weather type: ${this.config.type}`); + } + } else if (this.config.weatherEndpoint === "/weather") { + // Current weather endpoint (API v2.5) + weatherData = this.#generateWeatherObjectFromCurrentWeather(data); + } else if (this.config.weatherEndpoint === "/forecast") { + // 3-hourly forecast endpoint (API v2.5) + weatherData = this.config.type === "hourly" + ? this.#generateHourlyWeatherObjectsFromForecast(data) + : this.#generateDailyWeatherObjectsFromForecast(data); + } else { + throw new Error(`Unknown weather endpoint: ${this.config.weatherEndpoint}`); } if (weatherData && this.onDataCallback) { @@ -134,6 +147,123 @@ class OpenWeatherMapProvider { } } + #generateWeatherObjectFromCurrentWeather (data) { + const timezoneOffsetMinutes = (data.timezone ?? 0) / 60; + + if (data.name && data.sys?.country) { + this.locationName = `${data.name}, ${data.sys.country}`; + } else if (data.name) { + this.locationName = data.name; + } + + const weather = {}; + weather.date = weatherUtils.applyTimezoneOffset(new Date(data.dt * 1000), timezoneOffsetMinutes); + weather.temperature = data.main.temp; + weather.feelsLikeTemp = data.main.feels_like; + weather.humidity = data.main.humidity; + weather.windSpeed = data.wind.speed; + weather.windFromDirection = data.wind.deg; + weather.weatherType = weatherUtils.convertWeatherType(data.weather[0].icon); + weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunrise * 1000), timezoneOffsetMinutes); + weather.sunset = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunset * 1000), timezoneOffsetMinutes); + + return weather; + } + + #extractThreeHourPrecipitation (forecast) { + const rain = Number.parseFloat(forecast.rain?.["3h"] ?? "") || 0; + const snow = Number.parseFloat(forecast.snow?.["3h"] ?? "") || 0; + const precipitationAmount = rain + snow; + + return { + rain, + snow, + precipitationAmount, + hasPrecipitation: precipitationAmount > 0 + }; + } + + #generateHourlyWeatherObjectsFromForecast (data) { + const timezoneOffsetSeconds = data.city?.timezone ?? 0; + const timezoneOffsetMinutes = timezoneOffsetSeconds / 60; + + if (data.city?.name && data.city?.country) { + this.locationName = `${data.city.name}, ${data.city.country}`; + } + + return data.list.map((forecast) => { + const weather = {}; + weather.date = weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes); + weather.temperature = forecast.main.temp; + weather.feelsLikeTemp = forecast.main.feels_like; + weather.humidity = forecast.main.humidity; + weather.windSpeed = forecast.wind.speed; + weather.windFromDirection = forecast.wind.deg; + weather.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon); + weather.precipitationProbability = forecast.pop !== undefined ? forecast.pop * 100 : undefined; + + const precipitation = this.#extractThreeHourPrecipitation(forecast); + if (precipitation.hasPrecipitation) { + weather.rain = precipitation.rain; + weather.snow = precipitation.snow; + weather.precipitationAmount = precipitation.precipitationAmount; + } + + return weather; + }); + } + + #generateDailyWeatherObjectsFromForecast (data) { + const timezoneOffsetSeconds = data.city?.timezone ?? 0; + const timezoneOffsetMinutes = timezoneOffsetSeconds / 60; + + if (data.city?.name && data.city?.country) { + this.locationName = `${data.city.name}, ${data.city.country}`; + } + + const dayMap = new Map(); + + for (const forecast of data.list) { + // Shift dt by timezone offset so UTC fields represent local time + const localDate = new Date((forecast.dt + timezoneOffsetSeconds) * 1000); + const dateKey = `${localDate.getUTCFullYear()}-${String(localDate.getUTCMonth() + 1).padStart(2, "0")}-${String(localDate.getUTCDate()).padStart(2, "0")}`; + + if (!dayMap.has(dateKey)) { + dayMap.set(dateKey, { + date: weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes), + minTemps: [], + maxTemps: [], + rain: 0, + snow: 0, + weatherType: weatherUtils.convertWeatherType(forecast.weather[0].icon) + }); + } + + const day = dayMap.get(dateKey); + day.minTemps.push(forecast.main.temp_min); + day.maxTemps.push(forecast.main.temp_max); + + const hour = localDate.getUTCHours(); + if (hour >= 8 && hour <= 17) { + day.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon); + } + + const precipitation = this.#extractThreeHourPrecipitation(forecast); + day.rain += precipitation.rain; + day.snow += precipitation.snow; + } + + return Array.from(dayMap.values()).map((day) => ({ + date: day.date, + minTemperature: Math.min(...day.minTemps), + maxTemperature: Math.max(...day.maxTemps), + weatherType: day.weatherType, + rain: day.rain, + snow: day.snow, + precipitationAmount: day.rain + day.snow + })); + } + #generateWeatherObjectsFromOnecall (data) { let precip; diff --git a/defaultmodules/weather/weather.js b/defaultmodules/weather/weather.js index d3fbeb13ac..e40e41a4cd 100644 --- a/defaultmodules/weather/weather.js +++ b/defaultmodules/weather/weather.js @@ -5,7 +5,7 @@ Module.register("weather", { defaults: { weatherProvider: "openweathermap", roundTemp: false, - type: "current", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint) + type: "current", // current, forecast, daily (equivalent to forecast), hourly lang: config.language, units: config.units, tempUnits: config.units, @@ -242,7 +242,23 @@ Module.register("weather", { // Add all the data to the template. getTemplateData () { - const hourlyData = this.weatherHourlyArray?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1); + const now = new Date(); + // Filter out past entries, but keep the current hour (e.g. show 0:00 at 0:10). + // This ensures consistent behavior across all providers, regardless of whether + // a provider filters past entries itself. + const startOfHour = new Date(now); + startOfHour.setMinutes(0, 0, 0); + const upcomingHourlyData = this.weatherHourlyArray + ?.filter((entry) => entry.date?.valueOf() >= startOfHour.getTime()); + const hourlySourceData = upcomingHourlyData?.length ? upcomingHourlyData : this.weatherHourlyArray; + + const increment = this.config.hourlyForecastIncrements; + const keepByConfiguredIncrement = (_entry, index) => { + // Keep the existing offset behavior of hourlyForecastIncrements. + return (index + 1) % increment === increment - 1; + }; + + const hourlyData = hourlySourceData?.filter(keepByConfiguredIncrement); return { config: this.config, diff --git a/tests/mocks/weather_owm_current.json b/tests/mocks/weather_owm_current.json new file mode 100644 index 0000000000..021ae166bb --- /dev/null +++ b/tests/mocks/weather_owm_current.json @@ -0,0 +1,28 @@ +{ + "coord": { "lon": 11.58, "lat": 48.14 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "base": "stations", + "main": { + "temp": -0.27, + "feels_like": -3.9, + "temp_min": -1.0, + "temp_max": 0.5, + "pressure": 1018, + "humidity": 54 + }, + "visibility": 10000, + "wind": { "speed": 3.09, "deg": 220 }, + "clouds": { "all": 100 }, + "dt": 1744200000, + "sys": { + "type": 2, + "id": 2002112, + "country": "DE", + "sunrise": 1744170000, + "sunset": 1744218000 + }, + "timezone": 7200, + "id": 2867714, + "name": "Munich", + "cod": 200 +} diff --git a/tests/mocks/weather_owm_forecast.json b/tests/mocks/weather_owm_forecast.json new file mode 100644 index 0000000000..d9c3e3e075 --- /dev/null +++ b/tests/mocks/weather_owm_forecast.json @@ -0,0 +1,180 @@ +{ + "cod": "200", + "message": 0, + "cnt": 16, + "list": [ + { + "dt": 1744156800, + "main": { "temp": -1.0, "feels_like": -4.0, "temp_min": -1.5, "temp_max": -0.5, "pressure": 1018, "humidity": 60 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 100 }, + "wind": { "speed": 3.0, "deg": 210 }, + "pop": 0.2, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-09 00:00:00" + }, + { + "dt": 1744167600, + "main": { "temp": -1.2, "feels_like": -4.2, "temp_min": -1.5, "temp_max": -0.9, "pressure": 1018, "humidity": 62 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 100 }, + "wind": { "speed": 3.1, "deg": 215 }, + "pop": 0.2, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-09 03:00:00" + }, + { + "dt": 1744178400, + "main": { "temp": -0.5, "feels_like": -3.5, "temp_min": -1.0, "temp_max": 0.0, "pressure": 1019, "humidity": 58 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "clouds": { "all": 95 }, + "wind": { "speed": 2.8, "deg": 220 }, + "pop": 0.3, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 06:00:00" + }, + { + "dt": 1744189200, + "main": { "temp": 1.0, "feels_like": -2.0, "temp_min": 0.5, "temp_max": 1.5, "pressure": 1019, "humidity": 55 }, + "weather": [{ "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" }], + "clouds": { "all": 90 }, + "wind": { "speed": 2.5, "deg": 225 }, + "pop": 0.8, + "rain": { "3h": 0.6 }, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 09:00:00" + }, + { + "dt": 1744200000, + "main": { "temp": 2.0, "feels_like": -1.0, "temp_min": 1.5, "temp_max": 2.5, "pressure": 1018, "humidity": 52 }, + "weather": [{ "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" }], + "clouds": { "all": 88 }, + "wind": { "speed": 2.4, "deg": 230 }, + "pop": 0.9, + "rain": { "3h": 0.6 }, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 12:00:00" + }, + { + "dt": 1744210800, + "main": { "temp": 1.5, "feels_like": -1.5, "temp_min": 1.0, "temp_max": 2.0, "pressure": 1018, "humidity": 54 }, + "weather": [{ "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" }], + "clouds": { "all": 90 }, + "wind": { "speed": 2.6, "deg": 228 }, + "pop": 0.8, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 15:00:00" + }, + { + "dt": 1744221600, + "main": { "temp": 0.8, "feels_like": -2.2, "temp_min": 0.5, "temp_max": 1.2, "pressure": 1018, "humidity": 57 }, + "weather": [{ "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" }], + "clouds": { "all": 92 }, + "wind": { "speed": 2.7, "deg": 222 }, + "pop": 0.6, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 18:00:00" + }, + { + "dt": 1744232400, + "main": { "temp": -0.2, "feels_like": -3.2, "temp_min": -0.5, "temp_max": 0.1, "pressure": 1019, "humidity": 60 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 95 }, + "wind": { "speed": 2.9, "deg": 218 }, + "pop": 0.3, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-09 21:00:00" + }, + { + "dt": 1744243200, + "main": { "temp": 0.5, "feels_like": -2.5, "temp_min": 0.0, "temp_max": 1.0, "pressure": 1020, "humidity": 58 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 85 }, + "wind": { "speed": 2.5, "deg": 200 }, + "pop": 0.1, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-10 00:00:00" + }, + { + "dt": 1744254000, + "main": { "temp": 1.0, "feels_like": -2.0, "temp_min": 0.5, "temp_max": 1.5, "pressure": 1021, "humidity": 56 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 80 }, + "wind": { "speed": 2.3, "deg": 205 }, + "pop": 0.1, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-10 03:00:00" + }, + { + "dt": 1744264800, + "main": { "temp": 2.0, "feels_like": -1.0, "temp_min": 1.5, "temp_max": 2.5, "pressure": 1021, "humidity": 53 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "clouds": { "all": 75 }, + "wind": { "speed": 2.1, "deg": 210 }, + "pop": 0.1, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 06:00:00" + }, + { + "dt": 1744275600, + "main": { "temp": 3.5, "feels_like": 0.5, "temp_min": 3.0, "temp_max": 4.0, "pressure": 1020, "humidity": 50 }, + "weather": [{ "id": 520, "main": "Rain", "description": "light shower rain", "icon": "09d" }], + "clouds": { "all": 70 }, + "wind": { "speed": 2.0, "deg": 215 }, + "pop": 0.5, + "snow": { "3h": 0.5 }, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 09:00:00" + }, + { + "dt": 1744286400, + "main": { "temp": 5.0, "feels_like": 2.0, "temp_min": 4.5, "temp_max": 5.5, "pressure": 1019, "humidity": 48 }, + "weather": [{ "id": 520, "main": "Rain", "description": "light shower rain", "icon": "09d" }], + "clouds": { "all": 65 }, + "wind": { "speed": 1.9, "deg": 220 }, + "pop": 0.4, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 12:00:00" + }, + { + "dt": 1744297200, + "main": { "temp": 4.5, "feels_like": 1.5, "temp_min": 4.0, "temp_max": 5.0, "pressure": 1019, "humidity": 50 }, + "weather": [{ "id": 520, "main": "Rain", "description": "light shower rain", "icon": "09d" }], + "clouds": { "all": 68 }, + "wind": { "speed": 2.0, "deg": 218 }, + "pop": 0.4, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 15:00:00" + }, + { + "dt": 1744308000, + "main": { "temp": 3.0, "feels_like": 0.0, "temp_min": 2.5, "temp_max": 3.5, "pressure": 1019, "humidity": 53 }, + "weather": [{ "id": 520, "main": "Rain", "description": "light shower rain", "icon": "09d" }], + "clouds": { "all": 72 }, + "wind": { "speed": 2.1, "deg": 212 }, + "pop": 0.3, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 18:00:00" + }, + { + "dt": 1744318800, + "main": { "temp": 1.5, "feels_like": -1.5, "temp_min": 1.0, "temp_max": 2.0, "pressure": 1020, "humidity": 56 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 80 }, + "wind": { "speed": 2.2, "deg": 208 }, + "pop": 0.2, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-10 21:00:00" + } + ], + "city": { + "id": 2867714, + "name": "Munich", + "coord": { "lat": 48.14, "lon": 11.58 }, + "country": "DE", + "population": 1260391, + "timezone": 0, + "sunrise": 1744170000, + "sunset": 1744218000 + } +} diff --git a/tests/unit/modules/default/weather/providers/openweathermap_spec.js b/tests/unit/modules/default/weather/providers/openweathermap_spec.js index 7b27fd711f..d5b92ed6d6 100644 --- a/tests/unit/modules/default/weather/providers/openweathermap_spec.js +++ b/tests/unit/modules/default/weather/providers/openweathermap_spec.js @@ -8,6 +8,8 @@ import { setupServer } from "msw/node"; import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; import onecallData from "../../../../../mocks/weather_owm_onecall.json" with { type: "json" }; +import currentData from "../../../../../mocks/weather_owm_current.json" with { type: "json" }; +import forecastData from "../../../../../mocks/weather_owm_forecast.json" with { type: "json" }; let server; @@ -232,4 +234,321 @@ describe("OpenWeatherMapProvider", () => { expect(provider.locationName).toBe("America/New_York"); }); }); + + describe("API v2.5 - Current Weather (/weather endpoint)", () => { + it("should parse current weather from /weather endpoint", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/weather", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/weather", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.temperature).toBe(-0.27); + expect(result.feelsLikeTemp).toBe(-3.9); + expect(result.humidity).toBe(54); + expect(result.windSpeed).toBe(3.09); + expect(result.windFromDirection).toBe(220); + expect(result.weatherType).toBe("cloudy-windy"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should set location name from city name and country", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/weather", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/weather", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + await dataPromise; + + expect(provider.locationName).toBe("Munich, DE"); + }); + }); + + describe("API v2.5 - Forecast (/forecast endpoint)", () => { + it("should parse /forecast endpoint into daily grouped forecast", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + }); + + it("should correctly aggregate min/max temperatures per day", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Day 1: temp_min values: -1.5, -1.5, -1.0, 0.5, 1.5, 1.0, 0.5, -0.5 → min=-1.5 + expect(result[0].minTemperature).toBe(-1.5); + // Day 1: temp_max values: -0.5, -0.9, 0.0, 1.5, 2.5, 2.0, 1.2, 0.1 → max=2.5 + expect(result[0].maxTemperature).toBe(2.5); + // Day 2: temp_min values: 0.0, 0.5, 1.5, 3.0, 4.5, 4.0, 2.5, 1.0 → min=0.0 + expect(result[1].minTemperature).toBe(0.0); + // Day 2: temp_max values: 1.0, 1.5, 2.5, 4.0, 5.5, 5.0, 3.5, 2.0 → max=5.5 + expect(result[1].maxTemperature).toBe(5.5); + }); + + it("should pick daytime weather type (8-17h)", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Day 1 daytime entries have icon "10d" → "rain" + expect(result[0].weatherType).toBe("rain"); + // Day 2 daytime entries have icon "09d" → "showers" + expect(result[1].weatherType).toBe("showers"); + }); + + it("should accumulate precipitation per day", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Day 1: two rain entries of 0.6 each = 1.2 + expect(result[0].rain).toBeCloseTo(1.2); + expect(result[0].precipitationAmount).toBeCloseTo(1.2); + // Day 2: one snow entry of 0.5 + expect(result[1].snow).toBeCloseTo(0.5); + expect(result[1].precipitationAmount).toBeCloseTo(0.5); + }); + + it("should set location name from city in forecast response", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + await dataPromise; + + expect(provider.locationName).toBe("Munich, DE"); + }); + }); + + describe("API v2.5 - Hourly (/forecast endpoint with type hourly)", () => { + it("should return individual 3h entries instead of aggregating", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(forecastData.list.length); + }); + + it("should map temperature and wind from each 3h slot", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result[0].temperature).toBe(forecastData.list[0].main.temp); + expect(result[0].windSpeed).toBe(forecastData.list[0].wind.speed); + expect(result[0].precipitationProbability).toBe(forecastData.list[0].pop * 100); + }); + + it("should include precipitation when present in a slot", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Entry at index 3 has rain: { "3h": 0.6 } + expect(result[3].rain).toBe(0.6); + expect(result[3].precipitationAmount).toBe(0.6); + // Entry at index 11 has snow: { "3h": 0.5 } + expect(result[11].snow).toBe(0.5); + expect(result[11].precipitationAmount).toBe(0.5); + }); + }); });