import React from "react"; import { combineReducers, createStore } from "redux"; import { Provider } from "react-redux"; import { mount } from "enzyme"; 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, date_time: "2024-01-15T14:00:00", }, { epoch_date_time: 1000003600, temperature: { c: 17, f: 62 }, icon_id: 6, date_time: "2024-01-15T15:00:00", }, { epoch_date_time: 1000007200, temperature: { c: 16, f: 61 }, icon_id: 7, 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, }, }; function WrapWithProvider({ children, state = INITIAL_STATE }) { let store = createStore(combineReducers(reducers), state); return {children}; } describe("", () => { let wrapper; let sandbox; let dispatch; beforeEach(() => { sandbox = sinon.createSandbox(); dispatch = sandbox.stub(); wrapper = mount( ); }); afterEach(() => { sandbox.restore(); wrapper?.unmount(); }); it("should render weather forecast widget", () => { assert.ok(wrapper.exists()); assert.ok(wrapper.find(".weather-forecast-widget").exists()); }); it("should not render when detailed view is disabled", () => { const simpleViewState = { ...mockState, Prefs: { ...mockState.Prefs, values: { ...mockState.Prefs.values, "weather.display": "simple", }, }, }; wrapper = mount( ); assert.ok(!wrapper.find(".weather-forecast-widget").exists()); }); it("should not render when weather is disabled", () => { const weatherDisabledState = { ...mockState, Prefs: { ...mockState.Prefs, values: { ...mockState.Prefs.values, showWeather: false, }, }, }; wrapper = mount( ); assert.ok(!wrapper.find(".weather-forecast-widget").exists()); }); it("should display city name when search is inactive", () => { const cityName = wrapper.find(".city-name h3"); assert.ok(cityName.exists()); assert.equal(cityName.text(), "Testville"); }); it("should display LocationSearch component when search is active", () => { const searchActiveState = { ...mockState, Weather: { ...mockState.Weather, searchActive: true, }, }; wrapper = mount( ); assert.ok(wrapper.find("LocationSearch").exists()); assert.ok(!wrapper.find(".city-name h3").exists()); }); describe("context menu", () => { it("should render context menu with correct panel items", () => { assert.ok(wrapper.find(".weather-forecast-context-menu-button").exists()); assert.ok(wrapper.find("#weather-forecast-context-menu").exists()); assert.ok( wrapper .find( "panel-item[data-l10n-id='newtab-weather-menu-change-location']" ) .exists() ); assert.ok( wrapper .find( "panel-item[data-l10n-id='newtab-weather-menu-detect-my-location']" ) .exists() ); assert.ok( wrapper .find( "panel-item[data-l10n-id='newtab-weather-menu-change-temperature-units-celsius']" ) .exists() ); assert.ok( wrapper .find( "panel-item[data-l10n-id='newtab-weather-menu-change-weather-display-simple']" ) .exists() ); assert.ok( wrapper .find("panel-item[data-l10n-id='newtab-widget-menu-hide']") .exists() ); assert.ok( wrapper .find("panel-item[data-l10n-id='newtab-weather-menu-learn-more']") .exists() ); }); it("should not show 'Detect my location' when opt-in is disabled", () => { const noOptInState = { ...mockState, Prefs: { ...mockState.Prefs, values: { ...mockState.Prefs.values, "system.showWeatherOptIn": false, }, }, }; wrapper = mount( ); assert.isFalse( wrapper.contains( "panel-item[data-l10n-id='newtab-weather-menu-detect-my-location']" ) ); }); it("should show 'Change to Fahrenheit' when temperature unit is Celsius", () => { const celsiusState = { ...mockState, Prefs: { ...mockState.Prefs, values: { ...mockState.Prefs.values, "weather.temperatureUnits": "c", }, }, }; wrapper = mount( ); assert.ok( wrapper .find( "panel-item[data-l10n-id='newtab-weather-menu-change-temperature-units-fahrenheit']" ) .exists() ); }); it("should dispatch WEATHER_SEARCH_ACTIVE when 'Change location' is clicked", () => { wrapper = mount( ); const changeLocationItem = wrapper.find( "panel-item[data-l10n-id='newtab-weather-menu-change-location']" ); changeLocationItem.props().onClick(); assert.ok(dispatch.calledTwice); const [action] = dispatch.getCall(0).args; assert.equal(action.type, at.WEATHER_SEARCH_ACTIVE); assert.equal(action.data, true); // Verify telemetry const [telemetryAction] = dispatch.getCall(1).args; assert.equal(telemetryAction.type, at.WIDGETS_USER_EVENT); assert.equal(telemetryAction.data.widget_name, "weather"); assert.equal(telemetryAction.data.widget_source, "context_menu"); assert.equal(telemetryAction.data.user_action, "change_location"); assert.equal(telemetryAction.data.widget_size, "small"); }); it("should dispatch WEATHER_USER_OPT_IN_LOCATION when 'Detect my location' is clicked", () => { wrapper = mount( ); const detectLocationItem = wrapper.find( "panel-item[data-l10n-id='newtab-weather-menu-detect-my-location']" ); detectLocationItem.props().onClick(); assert.ok(dispatch.calledTwice); const [action] = dispatch.getCall(0).args; assert.equal(action.type, at.WEATHER_USER_OPT_IN_LOCATION); // Verify telemetry const [telemetryAction] = dispatch.getCall(1).args; assert.equal(telemetryAction.type, at.WIDGETS_USER_EVENT); assert.equal(telemetryAction.data.widget_name, "weather"); assert.equal(telemetryAction.data.widget_source, "context_menu"); assert.equal(telemetryAction.data.user_action, "detect_location"); assert.equal(telemetryAction.data.widget_size, "small"); }); it("should dispatch SET_PREF to change temperature units to Celsius", () => { wrapper = mount( ); const changeTempItem = wrapper.find( "panel-item[data-l10n-id='newtab-weather-menu-change-temperature-units-celsius']" ); changeTempItem.props().onClick(); assert.ok(dispatch.calledTwice); const [action] = dispatch.getCall(0).args; assert.equal(action.type, at.SET_PREF); assert.equal(action.data.name, "weather.temperatureUnits"); assert.equal(action.data.value, "c"); // Verify telemetry const [telemetryAction] = dispatch.getCall(1).args; assert.equal(telemetryAction.type, at.WIDGETS_USER_EVENT); assert.equal(telemetryAction.data.widget_name, "weather"); assert.equal(telemetryAction.data.widget_source, "context_menu"); assert.equal( telemetryAction.data.user_action, "change_temperature_units" ); assert.equal(telemetryAction.data.widget_size, "small"); assert.equal(telemetryAction.data.action_value, "c"); }); it("should dispatch SET_PREF to change display to simple", () => { wrapper = mount( ); const changeDisplayItem = wrapper.find( "panel-item[data-l10n-id='newtab-weather-menu-change-weather-display-simple']" ); changeDisplayItem.props().onClick(); assert.ok(dispatch.calledTwice); const [action] = dispatch.getCall(0).args; assert.equal(action.type, at.SET_PREF); assert.equal(action.data.name, "weather.display"); assert.equal(action.data.value, "simple"); // Verify telemetry const [telemetryAction] = dispatch.getCall(1).args; assert.equal(telemetryAction.type, at.WIDGETS_USER_EVENT); assert.equal(telemetryAction.data.widget_name, "weather"); assert.equal(telemetryAction.data.widget_source, "context_menu"); assert.equal(telemetryAction.data.user_action, "change_weather_display"); assert.equal(telemetryAction.data.widget_size, "small"); }); it("should dispatch SET_PREF to hide weather when 'Hide weather' is clicked", () => { wrapper = mount( ); const hideWeatherItem = wrapper.find( "panel-item[data-l10n-id='newtab-widget-menu-hide']" ); hideWeatherItem.props().onClick(); assert.ok(dispatch.calledTwice); const [action] = dispatch.getCall(0).args; assert.equal(action.type, at.SET_PREF); assert.equal(action.data.name, "showWeather"); assert.equal(action.data.value, false); // Verify telemetry const [telemetryAction] = dispatch.getCall(1).args; assert.equal(telemetryAction.type, at.WIDGETS_ENABLED); assert.equal(telemetryAction.data.widget_name, "weather"); assert.equal(telemetryAction.data.widget_source, "context_menu"); assert.equal(telemetryAction.data.enabled, false); assert.equal(telemetryAction.data.widget_size, "small"); }); it("should dispatch OPEN_LINK when 'Learn more' is clicked", () => { wrapper = mount( ); const learnMoreItem = wrapper.find( "panel-item[data-l10n-id='newtab-weather-menu-learn-more']" ); learnMoreItem.props().onClick(); assert.ok(dispatch.calledTwice); const [action] = dispatch.getCall(0).args; assert.equal(action.type, at.OPEN_LINK); assert.equal( action.data.url, "https://support.mozilla.org/kb/firefox-new-tab-widgets" ); // Verify telemetry const [telemetryAction] = dispatch.getCall(1).args; assert.equal(telemetryAction.type, at.WIDGETS_USER_EVENT); assert.equal(telemetryAction.data.widget_name, "weather"); assert.equal(telemetryAction.data.widget_source, "context_menu"); assert.equal(telemetryAction.data.user_action, "learn_more"); assert.equal(telemetryAction.data.widget_size, "small"); }); it("should report widget_size as 'medium' when widget is maximized", () => { wrapper = mount( ); const changeLocationItem = wrapper.find( "panel-item[data-l10n-id='newtab-weather-menu-change-location']" ); changeLocationItem.props().onClick(); const [telemetryAction] = dispatch.getCall(1).args; assert.equal(telemetryAction.type, at.WIDGETS_USER_EVENT); assert.equal(telemetryAction.data.widget_size, "medium"); }); }); describe("hourly forecast", () => { it("should display one row item per hourly forecast", () => { const items = wrapper.find(".forecast-row-items li"); assert.equal(items.length, hourlyForecasts.length); }); it("should render the correct weather icon class for each forecast item", () => { const items = wrapper.find(".forecast-row-items li"); items.forEach((item, index) => { assert.ok( item .find(`.weather-icon.iconId${hourlyForecasts[index].icon_id}`) .exists() ); }); }); it("should render an empty list when hourlyForecasts is empty", () => { const noHourlyState = { ...mockState, Weather: { ...mockState.Weather, hourlyForecasts: [], }, }; const noHourlyWrapper = mount( ); assert.equal(noHourlyWrapper.find(".forecast-row-items li").length, 0); noHourlyWrapper.unmount(); }); }); describe("error state", () => { it("should render error state when weather data is missing current_conditions", () => { const errorState = { ...mockState, Weather: { ...mockState.Weather, suggestions: [ { forecast: { high: { c: 25, f: 77 }, low: { c: 15, f: 59 }, url: "https://example.com", }, }, ], }, }; wrapper = mount( ); assert.ok(wrapper.find(".forecast-error").exists()); assert.ok( wrapper .find(".forecast-error") .find("p[data-l10n-id='newtab-weather-error-not-available']") .exists() ); }); it("should render error state when weather data is missing forecast", () => { const errorState = { ...mockState, Weather: { ...mockState.Weather, suggestions: [ { current_conditions: { icon_id: 3, summary: "Partly Cloudy", temperature: { c: 20, f: 68 }, }, }, ], }, }; wrapper = mount( ); assert.ok(wrapper.find(".forecast-error").exists()); }); it("should add forecast-error-state class when there is an error", () => { const errorState = { ...mockState, Weather: { ...mockState.Weather, suggestions: [{}], }, }; wrapper = mount( ); assert.ok( wrapper.find(".weather-forecast-widget.forecast-error-state").exists() ); }); it("should hide current weather info when error state is shown", () => { const errorState = { ...mockState, Weather: { ...mockState.Weather, suggestions: [{}], }, }; wrapper = mount( ); assert.ok(!wrapper.find(".current-weather-wrapper").exists()); assert.ok(wrapper.find(".forecast-error").exists()); }); it("should not render .forecast-anchor when there is an error", () => { const errorState = { ...mockState, Weather: { ...mockState.Weather, suggestions: [{}], }, }; wrapper = mount( ); assert.ok(!wrapper.find(".forecast-anchor").exists()); }); it("should render .forecast-anchor as an anchor tag when there is no error", () => { const anchor = wrapper.find(".forecast-anchor"); assert.ok(anchor.exists()); assert.equal(anchor.type(), "a"); assert.equal(anchor.prop("aria-label"), "Testville"); }); }); describe("provider link anchor", () => { it("should dispatch WIDGETS_USER_EVENT with provider_link_click when the anchor is clicked", () => { const anchor = wrapper.find(".forecast-anchor"); anchor.props().onClick(); assert.ok(dispatch.calledOnce); const [action] = dispatch.getCall(0).args; assert.equal(action.type, at.WIDGETS_USER_EVENT); assert.equal(action.data.widget_name, "weather"); assert.equal(action.data.widget_source, "widget"); assert.equal(action.data.user_action, "provider_link_click"); assert.equal(action.data.widget_size, "medium"); }); }); });