/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { render, fireEvent } from "@testing-library/react"; import { Provider } from "react-redux"; import { combineReducers, createStore } from "redux"; import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; import { actionTypes as at } from "common/Actions.mjs"; import { WeatherForecast } from "content-src/components/Widgets/WeatherForecast/WeatherForecast"; const weatherSuggestion = { current_conditions: { icon_id: 3, summary: "Partly Cloudy", temperature: { c: 20, f: 68, }, }, forecast: { high: { c: 25, f: 77, }, low: { c: 15, f: 59, }, url: "https://example.com", }, }; const hourlyForecasts = [ { epoch_date_time: 1000000000, temperature: { c: 18, f: 64 }, icon_id: 5, summary: "Partly Cloudy", date_time: "2024-01-15T14:00:00", url: "https://example.com/forecast", }, { epoch_date_time: 1000003600, temperature: { c: 17, f: 62 }, icon_id: 6, summary: "Mostly Cloudy", date_time: "2024-01-15T15:00:00", }, { epoch_date_time: 1000007200, temperature: { c: 16, f: 61 }, icon_id: 7, summary: "Cloudy", date_time: "2024-01-15T16:00:00", }, ]; const mockState = { ...INITIAL_STATE, Prefs: { ...INITIAL_STATE.Prefs, values: { ...INITIAL_STATE.Prefs.values, showWeather: true, "system.showWeather": true, "weather.display": "detailed", "weather.temperatureUnits": "f", "weather.locationSearchEnabled": true, "system.showWeatherOptIn": true, "widgets.system.weatherForecast.enabled": true, }, }, Weather: { initialized: true, searchActive: false, locationData: { city: "Testville", }, suggestions: [weatherSuggestion], hourlyForecasts, }, }; // nova.enabled with a medium size unlocks the size submenu and the size-based // layout gate that replaces the simple/detailed display toggle. const novaState = { ...mockState, Prefs: { ...mockState.Prefs, values: { ...mockState.Prefs.values, "nova.enabled": true, "widgets.weather.size": "medium", }, }, }; function WrapWithProvider({ children, state = INITIAL_STATE }) { const store = createStore(combineReducers(reducers), state); return {children}; } function renderForecast({ state = mockState, props = {}, dispatch = jest.fn(), } = {}) { const { container, unmount } = render( ); return { container, unmount, dispatch }; } // Builds a state with the temperature units pref overridden. function stateWithPref(name, value, base = mockState) { return { ...base, Prefs: { ...base.Prefs, values: { ...base.Prefs.values, [name]: value, }, }, }; } // Builds a state whose single weather suggestion is replaced, to exercise the // error branch (missing current_conditions/forecast). function stateWithSuggestion(suggestion) { return { ...mockState, Weather: { ...mockState.Weather, suggestions: [suggestion], }, }; } describe("", () => { it("should render weather forecast widget", () => { const { container } = renderForecast(); expect(container).toBeInTheDocument(); expect( container.querySelector(".weather-forecast-widget") ).toBeInTheDocument(); }); it("should not render when detailed view is disabled", () => { const { container } = renderForecast({ state: stateWithPref("weather.display", "simple"), }); expect( container.querySelector(".weather-forecast-widget") ).not.toBeInTheDocument(); }); it("should not render when weather is disabled", () => { const { container } = renderForecast({ state: stateWithPref("showWeather", false), }); expect( container.querySelector(".weather-forecast-widget") ).not.toBeInTheDocument(); }); it("should display city name when search is inactive", () => { const { container } = renderForecast(); const cityName = container.querySelector(".city-name h2"); expect(cityName).toBeInTheDocument(); expect(cityName).toHaveTextContent("Testville"); }); it("should display LocationSearch component when search is active", () => { const searchActiveState = { ...mockState, Weather: { ...mockState.Weather, searchActive: true, }, }; const { container } = renderForecast({ state: searchActiveState }); expect(container.querySelector(".location-search")).toBeInTheDocument(); expect(container.querySelector(".city-name h3")).not.toBeInTheDocument(); }); describe("high/low temperature aria-labels", () => { it("should have an aria-label on the high temperature arrow", () => { const { container } = renderForecast(); const highArrow = container.querySelector( ".high-temperature .arrow-icon.arrow-up" ); expect(highArrow).toBeInTheDocument(); expect(highArrow).toHaveAttribute("data-l10n-id", "newtab-weather-high"); }); it("should have an aria-label on the low temperature arrow", () => { const { container } = renderForecast(); const lowArrow = container.querySelector( ".low-temperature .arrow-icon.arrow-down" ); expect(lowArrow).toBeInTheDocument(); expect(lowArrow).toHaveAttribute("data-l10n-id", "newtab-weather-low"); }); }); describe("context menu", () => { it("should render context menu with correct panel items", () => { const { container } = renderForecast(); const menuButton = container.querySelector( ".weather-forecast-context-menu-button" ); expect(menuButton).toBeInTheDocument(); expect(menuButton).toHaveAttribute( "data-l10n-id", "newtab-menu-section-tooltip" ); expect( container.querySelector("#weather-forecast-context-menu") ).toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-location']" ) ).toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-detect-my-location']" ) ).toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-temperature-units-celsius']" ) ).toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-weather-display-simple']" ) ).toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-widget-menu-hide']" ) ).toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-learn-more']" ) ).toBeInTheDocument(); }); it("should not show 'Detect my location' when opt-in is disabled", () => { const { container } = renderForecast({ state: stateWithPref("system.showWeatherOptIn", false), }); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-detect-my-location']" ) ).not.toBeInTheDocument(); }); it("should show 'Change to Fahrenheit' when temperature unit is Celsius", () => { const { container } = renderForecast({ state: stateWithPref("weather.temperatureUnits", "c"), }); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-temperature-units-fahrenheit']" ) ).toBeInTheDocument(); }); it("should dispatch WEATHER_SEARCH_ACTIVE when 'Change location' is clicked", () => { const { container, dispatch } = renderForecast({ props: { isMaximized: false, widgetsMayBeMaximized: true }, }); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-location']" ) ); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.WEATHER_SEARCH_ACTIVE, data: true, }); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_name: "weather", widget_source: "context_menu", user_action: "change_location", widget_size: "small", }), }); }); it("should dispatch WEATHER_USER_OPT_IN_LOCATION when 'Detect my location' is clicked", () => { const { container, dispatch } = renderForecast({ props: { isMaximized: false, widgetsMayBeMaximized: true }, }); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-detect-my-location']" ) ); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.WEATHER_USER_OPT_IN_LOCATION, }); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_name: "weather", widget_source: "context_menu", user_action: "detect_location", widget_size: "small", }), }); }); it("should dispatch SET_PREF to change temperature units to Celsius", () => { const { container, dispatch } = renderForecast({ props: { isMaximized: false, widgetsMayBeMaximized: true }, }); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-temperature-units-celsius']" ) ); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.SET_PREF, data: { name: "weather.temperatureUnits", value: "c" }, }); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_name: "weather", widget_source: "context_menu", user_action: "change_temperature_units", widget_size: "small", action_value: "c", }), }); }); it("should dispatch SET_PREF to change display to simple", () => { const { container, dispatch } = renderForecast({ props: { isMaximized: false, widgetsMayBeMaximized: true }, }); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-weather-display-simple']" ) ); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.SET_PREF, data: { name: "weather.display", value: "simple" }, }); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_name: "weather", widget_source: "context_menu", user_action: "change_weather_display", widget_size: "small", }), }); }); it("should dispatch SET_PREF to hide weather when 'Hide weather' is clicked", () => { const { container, dispatch } = renderForecast({ props: { isMaximized: false, widgetsMayBeMaximized: true }, }); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-widget-menu-hide']" ) ); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.SET_PREF, data: { name: "showWeather", value: false }, }); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_ENABLED, data: expect.objectContaining({ widget_name: "weather", widget_source: "context_menu", enabled: false, widget_size: "small", }), }); }); it("should dispatch OPEN_LINK when 'Learn more' is clicked", () => { const { container, dispatch } = renderForecast({ props: { isMaximized: false, widgetsMayBeMaximized: true }, }); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-learn-more']" ) ); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.OPEN_LINK, data: { url: "https://support.mozilla.org/kb/firefox-new-tab-widgets", }, }); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_name: "weather", widget_source: "context_menu", user_action: "learn_more", widget_size: "small", }), }); }); it("should report widget_size as 'medium' when widget is maximized", () => { const { container, dispatch } = renderForecast({ props: { isMaximized: true }, }); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-location']" ) ); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_size: "medium" }), }); }); }); describe("hourly forecast", () => { it("should display one row item per hourly forecast", () => { const { container } = renderForecast(); expect(container.querySelectorAll(".forecast-row-items li")).toHaveLength( hourlyForecasts.length ); }); it("should render the correct weather icon class for each forecast item", () => { const { container } = renderForecast(); const items = container.querySelectorAll(".forecast-row-items li"); items.forEach((item, index) => { expect( item.querySelector( `.weather-icon.iconId${hourlyForecasts[index].icon_id}` ) ).toBeInTheDocument(); }); }); it("should render aria-label with summary for each weather icon", () => { const { container } = renderForecast(); const items = container.querySelectorAll(".forecast-row-items li"); items.forEach((item, index) => { expect(item.querySelector(".weather-icon")).toHaveAttribute( "aria-label", hourlyForecasts[index].summary ); }); }); it("should render an empty list when hourlyForecasts is empty", () => { const noHourlyState = { ...mockState, Weather: { ...mockState.Weather, hourlyForecasts: [], }, }; const { container } = renderForecast({ state: noHourlyState }); expect(container.querySelectorAll(".forecast-row-items li")).toHaveLength( 0 ); }); }); describe("error state", () => { it("should render error state when weather data is missing current_conditions", () => { const { container } = renderForecast({ state: stateWithSuggestion({ forecast: { high: { c: 25, f: 77 }, low: { c: 15, f: 59 }, url: "https://example.com", }, }), }); const error = container.querySelector(".forecast-error"); expect(error).toBeInTheDocument(); expect( error.querySelector( "p[data-l10n-id='newtab-weather-error-not-available']" ) ).toBeInTheDocument(); }); it("should render error state when weather data is missing forecast", () => { const { container } = renderForecast({ state: stateWithSuggestion({ current_conditions: { icon_id: 3, summary: "Partly Cloudy", temperature: { c: 20, f: 68 }, }, }), }); expect(container.querySelector(".forecast-error")).toBeInTheDocument(); }); it("should add forecast-error-state class when there is an error", () => { const { container } = renderForecast({ state: stateWithSuggestion({}), }); expect( container.querySelector(".weather-forecast-widget.forecast-error-state") ).toBeInTheDocument(); }); it("should hide current weather info when error state is shown", () => { const { container } = renderForecast({ state: stateWithSuggestion({}), }); expect( container.querySelector(".current-weather-wrapper") ).not.toBeInTheDocument(); expect(container.querySelector(".forecast-error")).toBeInTheDocument(); }); it("should not render .forecast-anchor when there is an error", () => { const { container } = renderForecast({ state: stateWithSuggestion({}), }); expect( container.querySelector(".forecast-anchor") ).not.toBeInTheDocument(); }); it("should render .forecast-anchor as an anchor tag when there is no error", () => { const { container } = renderForecast(); const anchor = container.querySelector(".forecast-anchor"); expect(anchor).toBeInTheDocument(); expect(anchor.tagName).toBe("A"); expect(anchor).toHaveAttribute("aria-label", "Testville"); }); }); describe("size submenu (nova)", () => { it("does not render submenu when nova is disabled", () => { const { container } = renderForecast(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-widget-menu-change-size']" ) ).not.toBeInTheDocument(); }); it("renders size submenu when nova is enabled", () => { const { container } = renderForecast({ state: novaState }); expect( container.querySelector( "panel-item[data-l10n-id='newtab-widget-menu-change-size']" ) ).toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-widget-size-small']" ) ).toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-widget-size-medium']" ) ).toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-widget-size-large']" ) ).toBeInTheDocument(); }); it("clicking 'Small' dispatches SET_PREF and WIDGETS_USER_EVENT", () => { const { container, dispatch } = renderForecast({ state: novaState }); const submenuNode = container.querySelector( "panel-list[id='weather-forecast-size-submenu']" ); const mockItem = document.createElement("div"); mockItem.dataset.size = "small"; const clickEvent = new MouseEvent("click", { bubbles: true }); Object.defineProperty(clickEvent, "composedPath", { value: () => [mockItem], }); submenuNode.dispatchEvent(clickEvent); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.SET_PREF, data: { name: "widgets.weather.size", value: "small" }, }); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_name: "weather", widget_source: "context_menu", user_action: "change_size", action_value: "small", widget_size: "small", }), }); }); it("clicking 'Large' dispatches SET_PREF and WIDGETS_USER_EVENT", () => { const { container, dispatch } = renderForecast({ state: novaState }); const submenuNode = container.querySelector( "panel-list[id='weather-forecast-size-submenu']" ); const mockItem = document.createElement("div"); mockItem.dataset.size = "large"; const clickEvent = new MouseEvent("click", { bubbles: true }); Object.defineProperty(clickEvent, "composedPath", { value: () => [mockItem], }); submenuNode.dispatchEvent(clickEvent); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.SET_PREF, data: { name: "widgets.weather.size", value: "large" }, }); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_name: "weather", widget_source: "context_menu", user_action: "change_size", action_value: "large", widget_size: "large", }), }); }); it("widget_size in telemetry uses pref value when nova is enabled", () => { const largeNovaState = stateWithPref( "widgets.weather.size", "large", novaState ); const { container, dispatch } = renderForecast({ state: largeNovaState }); fireEvent.click( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-location']" ) ); expect(dispatch.mock.calls[1][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_size: "large" }), }); }); it("isSmallSize is true when not maximized and widgetsMayBeMaximized is set", () => { const { container } = renderForecast({ props: { isMaximized: false, widgetsMayBeMaximized: true }, }); expect(container.querySelector(".is-small")).toBeInTheDocument(); }); it("nova=on, size=medium renders compact layout (.is-small present)", () => { const mediumNovaState = stateWithPref( "widgets.weather.size", "medium", novaState ); const { container } = renderForecast({ state: mediumNovaState }); expect(container.querySelector(".is-small")).toBeInTheDocument(); }); it("nova=on, size=large renders full layout (.is-small absent)", () => { const largeNovaState = stateWithPref( "widgets.weather.size", "large", novaState ); const { container } = renderForecast({ state: largeNovaState }); expect(container.querySelector(".is-small")).not.toBeInTheDocument(); }); }); describe("nova display gate and menu", () => { it("does not render when nova is enabled and size is small", () => { const novaSmallState = stateWithPref( "widgets.weather.size", "small", novaState ); const { container } = renderForecast({ state: novaSmallState }); expect( container.querySelector(".weather-forecast-widget") ).not.toBeInTheDocument(); }); it("renders without weather.display=detailed when nova is enabled", () => { const novaSimpleState = stateWithPref( "weather.display", "simple", novaState ); const { container } = renderForecast({ state: novaSimpleState }); expect( container.querySelector(".weather-forecast-widget") ).toBeInTheDocument(); }); it("hides CHANGE_DISPLAY items when nova is enabled", () => { const { container } = renderForecast({ state: novaState }); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-weather-display-detailed']" ) ).not.toBeInTheDocument(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-weather-display-simple']" ) ).not.toBeInTheDocument(); }); it("shows CHANGE_DISPLAY items when nova is disabled", () => { const { container } = renderForecast(); expect( container.querySelector( "panel-item[data-l10n-id='newtab-weather-menu-change-weather-display-simple']" ) ).toBeInTheDocument(); }); it("checked state marks the current size", () => { const { container } = renderForecast({ state: novaState }); expect( container .querySelector("panel-item[data-l10n-id='newtab-widget-size-medium']") .hasAttribute("checked") ).toBe(true); expect( container .querySelector("panel-item[data-l10n-id='newtab-widget-size-small']") .hasAttribute("checked") ).toBe(false); expect( container .querySelector("panel-item[data-l10n-id='newtab-widget-size-large']") .hasAttribute("checked") ).toBe(false); }); }); describe("provider link anchor", () => { it("should dispatch WIDGETS_USER_EVENT with provider_link_click when the anchor is clicked", () => { const { container, dispatch } = renderForecast(); fireEvent.click(container.querySelector(".forecast-anchor")); expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_name: "weather", widget_source: "widget", user_action: "provider_link_click", widget_size: "medium", }), }); }); it("should render .full-forecast as an anchor with the forecast URL", () => { const { container } = renderForecast(); const link = container.querySelector("a.full-forecast"); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute("href", hourlyForecasts[0].url); }); it("should dispatch WIDGETS_USER_EVENT with provider_link_click when .full-forecast is clicked", () => { const { container, dispatch } = renderForecast(); fireEvent.click(container.querySelector("a.full-forecast")); expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch.mock.calls[0][0]).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: expect.objectContaining({ widget_name: "weather", widget_source: "widget", user_action: "provider_link_click", }), }); }); }); });