diff --git a/config.ini.example b/config.ini.example index 351e750..b9d3228 100644 --- a/config.ini.example +++ b/config.ini.example @@ -656,6 +656,10 @@ weather_provider = noaa # Set to an empty value (weather_model =) to omit model selection and let Open-Meteo auto-select #weather_model = +# Default city name for empty "wx" / "gwx" commands when companion location is unavailable +# Use English city names (examples: Seattle or Seattle, WA) +#default_city = + # Default state for city name disambiguation # When users type "wx seattle", it will search for "seattle, WA, USA" # Use 2-letter state abbreviation (e.g., WA, CA, NY, TX) diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index ba2cfe6..45576cf 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -80,7 +80,8 @@ class GlobalWxCommand(BaseCommand): self.weather_model = self._load_weather_model() - # Get default state and country from config for city disambiguation + # Get default location/state/country from config for fallback/disambiguation + self.default_city = self.bot.config.get('Weather', 'default_city', fallback='').strip() self.default_state = self.bot.config.get('Weather', 'default_state', fallback='') self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US') @@ -460,36 +461,47 @@ class GlobalWxCommand(BaseCommand): parts = [parts[0], location_str] self.logger.info(f"Using companion coordinates: {location_str}") else: - # No companion location: optionally use bot's configured coordinates - use_bot = self.get_config_value( - 'Wx_Command', - 'use_bot_location_when_no_location', - fallback=False, - value_type='bool', - ) - bot_loc = self._get_bot_location() if use_bot else None - if bot_loc: - location_str = self._coordinates_to_location_string(bot_loc[0], bot_loc[1]) - if location_str: - parts = [parts[0], location_str] - self.logger.info( - f"Using bot location (no args): {location_str} " - f"({bot_loc[0]}, {bot_loc[1]})" - ) - else: - location_str = f"{bot_loc[0]},{bot_loc[1]}" - parts = [parts[0], location_str] - self.logger.info(f"Using bot coordinates (no args): {location_str}") + # No companion location: use default city if configured, then bot location fallback + if self.default_city: + location_parts = [self.default_city] + if self.default_state: + location_parts.append(self.default_state) + if self.default_country: + location_parts.append(self.default_country) + location_str = ", ".join(location_parts) + parts = [parts[0], location_str] + self.logger.info(f"Using default city (no args): {location_str}") else: - if use_bot: - self.logger.debug( - "use_bot_location_when_no_location enabled but bot_latitude/bot_longitude " - "not set; showing usage" - ) + # No default city: optionally use bot's configured coordinates + use_bot = self.get_config_value( + 'Wx_Command', + 'use_bot_location_when_no_location', + fallback=False, + value_type='bool', + ) + bot_loc = self._get_bot_location() if use_bot else None + if bot_loc: + location_str = self._coordinates_to_location_string(bot_loc[0], bot_loc[1]) + if location_str: + parts = [parts[0], location_str] + self.logger.info( + f"Using bot location (no args): {location_str} " + f"({bot_loc[0]}, {bot_loc[1]})" + ) + else: + location_str = f"{bot_loc[0]},{bot_loc[1]}" + parts = [parts[0], location_str] + self.logger.info(f"Using bot coordinates (no args): {location_str}") else: - self.logger.debug("No companion location found, showing usage") - await self.send_response(message, self.translate('commands.gwx.usage')) - return True + if use_bot: + self.logger.debug( + "use_bot_location_when_no_location enabled but bot_latitude/bot_longitude " + "not set; showing usage" + ) + else: + self.logger.debug("No companion/default city location found, showing usage") + await self.send_response(message, self.translate('commands.gwx.usage')) + return True # Check for forecast type options: "tomorrow", Nd (7d, 10d), or plain digit days 2–GWX_MULTIDAY_MAX_DAYS forecast_type = "default" diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index f234bbf..2b38754 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -105,7 +105,8 @@ class WxCommand(BaseCommand): self.use_metric = False # Use imperial units by default self.zulu_time = False # Use local time by default - # Get default state and country from config for city disambiguation + # Get default location/state/country from config for fallback/disambiguation + self.default_city = self.bot.config.get('Weather', 'default_city', fallback='').strip() self.default_state = self.bot.config.get('Weather', 'default_state', fallback='') self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US') @@ -527,34 +528,45 @@ class WxCommand(BaseCommand): else: self.logger.info(f"Using companion coordinates: {location_str}") else: - # No companion location: optionally use bot's configured coordinates - use_bot = self.get_config_value( - 'Wx_Command', - 'use_bot_location_when_no_location', - fallback=False, - value_type='bool', - ) - bot_loc = self._get_bot_location() if use_bot else None - if bot_loc: - location_str = f"{bot_loc[0]},{bot_loc[1]}" + # No companion location: use default city if configured, then bot location fallback + if self.default_city: + location_parts = [self.default_city] + if self.default_state: + location_parts.append(self.default_state) + if self.default_country: + location_parts.append(self.default_country) + location_str = ", ".join(location_parts) parts = [parts[0], location_str] - display_name = self._coordinates_to_location_string(bot_loc[0], bot_loc[1]) - if display_name: - self.logger.info( - f"Using bot location (no args): {display_name} ({bot_loc[0]}, {bot_loc[1]})" - ) - else: - self.logger.info(f"Using bot coordinates (no args): {location_str}") + self.logger.info(f"Using default city (no args): {location_str}") else: - if use_bot: - self.logger.debug( - "use_bot_location_when_no_location enabled but bot_latitude/bot_longitude " - "not set; showing usage" - ) + # No default city: optionally use bot's configured coordinates + use_bot = self.get_config_value( + 'Wx_Command', + 'use_bot_location_when_no_location', + fallback=False, + value_type='bool', + ) + bot_loc = self._get_bot_location() if use_bot else None + if bot_loc: + location_str = f"{bot_loc[0]},{bot_loc[1]}" + parts = [parts[0], location_str] + display_name = self._coordinates_to_location_string(bot_loc[0], bot_loc[1]) + if display_name: + self.logger.info( + f"Using bot location (no args): {display_name} ({bot_loc[0]}, {bot_loc[1]})" + ) + else: + self.logger.info(f"Using bot coordinates (no args): {location_str}") else: - self.logger.debug("No companion location found, showing usage") - await self.send_response(message, self.translate('commands.wx.usage')) - return True + if use_bot: + self.logger.debug( + "use_bot_location_when_no_location enabled but bot_latitude/bot_longitude " + "not set; showing usage" + ) + else: + self.logger.debug("No companion/default city location found, showing usage") + await self.send_response(message, self.translate('commands.wx.usage')) + return True # Check for "alerts" keyword first (special handling) show_full_alerts = False diff --git a/tests/unit/test_weather_default_city_fallback.py b/tests/unit/test_weather_default_city_fallback.py new file mode 100644 index 0000000..abad45b --- /dev/null +++ b/tests/unit/test_weather_default_city_fallback.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Unit tests for default_city fallback ordering in wx and gwx commands.""" + +import asyncio +import configparser +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from modules.commands.alternatives.wx_international import GlobalWxCommand +from modules.commands.wx_command import WxCommand + + +def _build_config(default_city: str = "", use_bot: bool = False) -> configparser.ConfigParser: + config = configparser.ConfigParser() + config.add_section("Weather") + config.set("Weather", "weather_provider", "noaa") + config.set("Weather", "default_city", default_city) + config.set("Weather", "default_state", "WA") + config.set("Weather", "default_country", "US") + config.add_section("Wx_Command") + config.set( + "Wx_Command", + "use_bot_location_when_no_location", + "true" if use_bot else "false", + ) + config.add_section("Bot") + config.set("Bot", "bot_tx_rate_limit_seconds", "1.0") + return config + + +def _build_bot(config: configparser.ConfigParser, mock_logger): + bot = Mock() + bot.logger = mock_logger + bot.config = config + bot.db_manager = Mock() + bot.translator = Mock() + bot.translator.translate = Mock(side_effect=lambda key, **kwargs: key) + bot.command_manager = Mock() + bot.command_manager.monitor_channels = ["general"] + bot.command_manager.send_response = AsyncMock(return_value=True) + return bot + + +def _mock_message(content: str) -> SimpleNamespace: + return SimpleNamespace( + content=content, + sender_id="user1", + sender_pubkey="pubkey1", + channel="general", + is_dm=False, + ) + + +def _stub_shared_paths(cmd): + cmd._get_custom_mqtt_weather_topic = Mock(return_value=None) + cmd._get_custom_wxsim_source = Mock(return_value=None) + cmd.send_response = AsyncMock(return_value=True) + cmd.record_execution = Mock() + cmd.translate = Mock(side_effect=lambda key, **kwargs: key) + + +def test_gwx_empty_prefers_companion_over_default_city(mock_logger): + cmd = GlobalWxCommand(_build_bot(_build_config(default_city="Seattle"), mock_logger)) + _stub_shared_paths(cmd) + cmd._get_companion_location = Mock(return_value=(1, 2)) + cmd._coordinates_to_location_string = Mock(return_value=None) + cmd.get_weather_for_location = AsyncMock(return_value="ok") + + asyncio.run(cmd.execute(_mock_message("gwx"))) + + assert cmd.get_weather_for_location.await_count == 1 + assert cmd.get_weather_for_location.await_args.args[0] == "1,2" + + +def test_gwx_empty_uses_default_city_when_no_companion(mock_logger): + cmd = GlobalWxCommand(_build_bot(_build_config(default_city="Seattle"), mock_logger)) + _stub_shared_paths(cmd) + cmd._get_companion_location = Mock(return_value=None) + cmd.get_weather_for_location = AsyncMock(return_value="ok") + + asyncio.run(cmd.execute(_mock_message("gwx"))) + + assert cmd.get_weather_for_location.await_count == 1 + assert cmd.get_weather_for_location.await_args.args[0] == "Seattle, WA, US" + + +def test_gwx_empty_falls_back_to_bot_location_when_default_city_missing(mock_logger): + cmd = GlobalWxCommand(_build_bot(_build_config(default_city="", use_bot=True), mock_logger)) + _stub_shared_paths(cmd) + cmd._get_companion_location = Mock(return_value=None) + cmd._get_bot_location = Mock(return_value=(47, -122)) + cmd._coordinates_to_location_string = Mock(return_value=None) + cmd.get_weather_for_location = AsyncMock(return_value="ok") + + asyncio.run(cmd.execute(_mock_message("gwx"))) + + assert cmd.get_weather_for_location.await_count == 1 + assert cmd.get_weather_for_location.await_args.args[0] == "47,-122" + + +def test_gwx_empty_shows_usage_without_any_fallback(mock_logger): + cmd = GlobalWxCommand(_build_bot(_build_config(default_city="", use_bot=False), mock_logger)) + _stub_shared_paths(cmd) + cmd._get_companion_location = Mock(return_value=None) + cmd.get_weather_for_location = AsyncMock(return_value="ok") + + asyncio.run(cmd.execute(_mock_message("gwx"))) + + assert cmd.get_weather_for_location.await_count == 0 + assert cmd.send_response.await_count == 1 + assert cmd.send_response.await_args.args[1] == "commands.gwx.usage" + + +def test_wx_empty_prefers_companion_over_default_city(mock_logger): + cmd = WxCommand(_build_bot(_build_config(default_city="Seattle"), mock_logger)) + _stub_shared_paths(cmd) + cmd._get_companion_location = Mock(return_value=(1, 2)) + cmd._coordinates_to_location_string = Mock(return_value=None) + cmd.get_weather_for_location = AsyncMock(return_value="ok") + + asyncio.run(cmd.execute(_mock_message("wx"))) + + assert cmd.get_weather_for_location.await_count == 1 + assert cmd.get_weather_for_location.await_args.args[0] == "1,2" + assert cmd.get_weather_for_location.await_args.kwargs["using_companion_location"] is True + + +def test_wx_empty_uses_default_city_when_no_companion(mock_logger): + cmd = WxCommand(_build_bot(_build_config(default_city="Seattle"), mock_logger)) + _stub_shared_paths(cmd) + cmd._get_companion_location = Mock(return_value=None) + cmd.get_weather_for_location = AsyncMock(return_value="ok") + + asyncio.run(cmd.execute(_mock_message("wx"))) + + assert cmd.get_weather_for_location.await_count == 1 + assert cmd.get_weather_for_location.await_args.args[0] == "Seattle, WA, US" + assert cmd.get_weather_for_location.await_args.kwargs["using_companion_location"] is False + + +def test_wx_empty_falls_back_to_bot_location_when_default_city_missing(mock_logger): + cmd = WxCommand(_build_bot(_build_config(default_city="", use_bot=True), mock_logger)) + _stub_shared_paths(cmd) + cmd._get_companion_location = Mock(return_value=None) + cmd._get_bot_location = Mock(return_value=(47, -122)) + cmd._coordinates_to_location_string = Mock(return_value=None) + cmd.get_weather_for_location = AsyncMock(return_value="ok") + + asyncio.run(cmd.execute(_mock_message("wx"))) + + assert cmd.get_weather_for_location.await_count == 1 + assert cmd.get_weather_for_location.await_args.args[0] == "47,-122" + assert cmd.get_weather_for_location.await_args.kwargs["using_companion_location"] is False + + +def test_wx_empty_shows_usage_without_any_fallback(mock_logger): + cmd = WxCommand(_build_bot(_build_config(default_city="", use_bot=False), mock_logger)) + _stub_shared_paths(cmd) + cmd._get_companion_location = Mock(return_value=None) + cmd.get_weather_for_location = AsyncMock(return_value="ok") + + asyncio.run(cmd.execute(_mock_message("wx"))) + + assert cmd.get_weather_for_location.await_count == 0 + assert cmd.send_response.await_count == 1 + assert cmd.send_response.await_args.args[1] == "commands.wx.usage"