From 055f57132368d2f06c8f75a150c15b5674e59df4 Mon Sep 17 00:00:00 2001 From: Orinks <38449772+Orinks@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:05:51 -0500 Subject: [PATCH 1/6] test(weather-condition-analyzer): add comprehensive tests for WeatherConditionAnalyzer (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Added 47 comprehensive tests for the WeatherConditionAnalyzer module, which was previously at 0% coverage. ## Test Coverage - **Weather codes**: All Open-Meteo weather codes (clear, cloudy, fog, drizzle, rain, freezing, snow, thunderstorms) - **Temperature analysis**: Extreme cold to extreme hot thresholds - **Wind analysis**: Calm to extreme wind conditions - **Alert handling**: Severity mapping, priority selection, template override - **Priority scoring**: Base scores, temperature bonuses, wind bonuses - **Template selection**: All 7 template types (default, alert, severe_weather, temperature_extreme, wind_warning, precipitation, fog) - **Error handling**: Empty data, invalid data with graceful fallback ## Results - **Tests**: 47 new tests - **Coverage**: 0% → 98% - **All lint checks pass** Closes #254 --- tests/test_weather_condition_analyzer.py | 611 +++++++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 tests/test_weather_condition_analyzer.py diff --git a/tests/test_weather_condition_analyzer.py b/tests/test_weather_condition_analyzer.py new file mode 100644 index 00000000..e050f0dc --- /dev/null +++ b/tests/test_weather_condition_analyzer.py @@ -0,0 +1,611 @@ +""" +Tests for weather_condition_analyzer module. + +Covers: +- WEATHER_CODE_MAPPING for different weather codes +- Temperature threshold analysis +- Wind speed threshold analysis +- Format string template generation +- Severity and category classification +- Alert analysis +- Priority score calculation +""" + + +from accessiweather.weather_condition_analyzer import ( + ConditionCategory, + WeatherConditionAnalyzer, + WeatherSeverity, +) + + +class TestWeatherCodeMapping: + """Test WEATHER_CODE_MAPPING coverage.""" + + def test_clear_conditions(self): + """Clear/sunny codes map to CLEAR category with NORMAL severity.""" + analyzer = WeatherConditionAnalyzer() + + for code in [0, 1]: + category, severity = analyzer.WEATHER_CODE_MAPPING[code] + assert category == ConditionCategory.CLEAR + assert severity == WeatherSeverity.NORMAL + + def test_cloudy_conditions(self): + """Cloudy codes map to CLOUDY category with NORMAL severity.""" + analyzer = WeatherConditionAnalyzer() + + for code in [2, 3]: + category, severity = analyzer.WEATHER_CODE_MAPPING[code] + assert category == ConditionCategory.CLOUDY + assert severity == WeatherSeverity.NORMAL + + def test_fog_conditions(self): + """Fog codes map to FOG category with varying severity.""" + analyzer = WeatherConditionAnalyzer() + + cat, sev = analyzer.WEATHER_CODE_MAPPING[45] + assert cat == ConditionCategory.FOG + assert sev == WeatherSeverity.MINOR + + cat, sev = analyzer.WEATHER_CODE_MAPPING[48] + assert cat == ConditionCategory.FOG + assert sev == WeatherSeverity.MODERATE + + def test_drizzle_conditions(self): + """Drizzle codes map to PRECIPITATION category.""" + analyzer = WeatherConditionAnalyzer() + + for code in [51, 53]: + cat, sev = analyzer.WEATHER_CODE_MAPPING[code] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.MINOR + + cat, sev = analyzer.WEATHER_CODE_MAPPING[55] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.MODERATE + + def test_freezing_drizzle_conditions(self): + """Freezing drizzle codes map to FREEZING category.""" + analyzer = WeatherConditionAnalyzer() + + cat, sev = analyzer.WEATHER_CODE_MAPPING[56] + assert cat == ConditionCategory.FREEZING + assert sev == WeatherSeverity.MODERATE + + cat, sev = analyzer.WEATHER_CODE_MAPPING[57] + assert cat == ConditionCategory.FREEZING + assert sev == WeatherSeverity.SEVERE + + def test_rain_conditions(self): + """Rain codes map to PRECIPITATION with varying severity.""" + analyzer = WeatherConditionAnalyzer() + + cat, sev = analyzer.WEATHER_CODE_MAPPING[61] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.MINOR + + cat, sev = analyzer.WEATHER_CODE_MAPPING[63] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.MODERATE + + cat, sev = analyzer.WEATHER_CODE_MAPPING[65] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.SEVERE + + def test_freezing_rain_conditions(self): + """Freezing rain codes map to FREEZING with high severity.""" + analyzer = WeatherConditionAnalyzer() + + cat, sev = analyzer.WEATHER_CODE_MAPPING[66] + assert cat == ConditionCategory.FREEZING + assert sev == WeatherSeverity.SEVERE + + cat, sev = analyzer.WEATHER_CODE_MAPPING[67] + assert cat == ConditionCategory.FREEZING + assert sev == WeatherSeverity.EXTREME + + def test_snow_conditions(self): + """Snow codes map to PRECIPITATION with varying severity.""" + analyzer = WeatherConditionAnalyzer() + + cat, sev = analyzer.WEATHER_CODE_MAPPING[71] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.MODERATE + + cat, sev = analyzer.WEATHER_CODE_MAPPING[73] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.SEVERE + + cat, sev = analyzer.WEATHER_CODE_MAPPING[75] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.EXTREME + + cat, sev = analyzer.WEATHER_CODE_MAPPING[77] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.MINOR + + def test_rain_showers_conditions(self): + """Rain shower codes map to PRECIPITATION.""" + analyzer = WeatherConditionAnalyzer() + + for code, expected_sev in [(80, WeatherSeverity.MINOR), + (81, WeatherSeverity.MODERATE), + (82, WeatherSeverity.SEVERE)]: + cat, sev = analyzer.WEATHER_CODE_MAPPING[code] + assert cat == ConditionCategory.PRECIPITATION + assert sev == expected_sev + + def test_snow_showers_conditions(self): + """Snow shower codes map to PRECIPITATION.""" + analyzer = WeatherConditionAnalyzer() + + cat, sev = analyzer.WEATHER_CODE_MAPPING[85] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.MODERATE + + cat, sev = analyzer.WEATHER_CODE_MAPPING[86] + assert cat == ConditionCategory.PRECIPITATION + assert sev == WeatherSeverity.SEVERE + + def test_thunderstorm_conditions(self): + """Thunderstorm codes map to THUNDERSTORM with high severity.""" + analyzer = WeatherConditionAnalyzer() + + cat, sev = analyzer.WEATHER_CODE_MAPPING[95] + assert cat == ConditionCategory.THUNDERSTORM + assert sev == WeatherSeverity.SEVERE + + for code in [96, 99]: + cat, sev = analyzer.WEATHER_CODE_MAPPING[code] + assert cat == ConditionCategory.THUNDERSTORM + assert sev == WeatherSeverity.EXTREME + + +class TestTemperatureAnalysis: + """Test temperature threshold analysis.""" + + def test_extreme_cold(self): + """Temps at or below 0F are extreme cold.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_temperature({"temp": 0}) + assert result["temperature_extreme"] == "extreme_cold" + + result = analyzer._analyze_temperature({"temp": -10}) + assert result["temperature_extreme"] == "extreme_cold" + + def test_very_cold(self): + """Temps between 1-20F are very cold.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_temperature({"temp": 15}) + assert result["temperature_extreme"] == "very_cold" + + result = analyzer._analyze_temperature({"temp": 20}) + assert result["temperature_extreme"] == "very_cold" + + def test_cold(self): + """Temps between 21-32F are cold.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_temperature({"temp": 25}) + assert result["temperature_extreme"] == "cold" + + result = analyzer._analyze_temperature({"temp": 32}) + assert result["temperature_extreme"] == "cold" + + def test_normal_temperature(self): + """Temps between 33-89F have no extreme.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_temperature({"temp": 70}) + assert result["temperature_extreme"] is None + + def test_hot(self): + """Temps between 90-99F are hot.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_temperature({"temp": 90}) + assert result["temperature_extreme"] == "hot" + + result = analyzer._analyze_temperature({"temp": 95}) + assert result["temperature_extreme"] == "hot" + + def test_very_hot(self): + """Temps between 100-109F are very hot.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_temperature({"temp": 100}) + assert result["temperature_extreme"] == "very_hot" + + result = analyzer._analyze_temperature({"temp": 105}) + assert result["temperature_extreme"] == "very_hot" + + def test_extreme_hot(self): + """Temps at or above 110F are extreme hot.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_temperature({"temp": 110}) + assert result["temperature_extreme"] == "extreme_hot" + + result = analyzer._analyze_temperature({"temp": 120}) + assert result["temperature_extreme"] == "extreme_hot" + + def test_temp_f_fallback_key(self): + """Should use temp_f if temp is not present.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_temperature({"temp_f": -5}) + assert result["temperature_extreme"] == "extreme_cold" + + def test_missing_temperature(self): + """Should return None for missing temperature.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_temperature({}) + assert result["temperature_extreme"] is None + + +class TestWindAnalysis: + """Test wind speed threshold analysis.""" + + def test_calm_wind(self): + """Wind under 5 mph is calm.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_wind({"wind_speed": 3}) + assert result["wind_condition"] == "calm" + + def test_light_wind(self): + """Wind 15-24 mph is light.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_wind({"wind_speed": 15}) + assert result["wind_condition"] == "light" + + def test_moderate_wind(self): + """Wind 25-34 mph is moderate.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_wind({"wind_speed": 25}) + assert result["wind_condition"] == "moderate" + + def test_strong_wind(self): + """Wind 35-44 mph is strong.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_wind({"wind_speed": 35}) + assert result["wind_condition"] == "strong" + + def test_very_strong_wind(self): + """Wind 45-59 mph is very strong.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_wind({"wind_speed": 50}) + assert result["wind_condition"] == "very_strong" + + def test_extreme_wind(self): + """Wind 60+ mph is extreme.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_wind({"wind_speed": 70}) + assert result["wind_condition"] == "extreme" + + def test_missing_wind_speed(self): + """Should return None for missing wind speed.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_wind({}) + assert result["wind_condition"] is None + + +class TestAlertAnalysis: + """Test alert severity analysis.""" + + def test_no_alerts(self): + """Should return has_alerts False for empty alerts.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._analyze_alerts([]) + assert result["has_alerts"] is False + assert result["alert_severity"] is None + + def test_single_alert(self): + """Should analyze single alert correctly.""" + analyzer = WeatherConditionAnalyzer() + alerts = [{"severity": "Severe", "event": "Tornado Warning"}] + result = analyzer._analyze_alerts(alerts) + + assert result["has_alerts"] is True + assert result["alert_severity"] == WeatherSeverity.SEVERE + assert result["primary_alert"] == alerts[0] + + def test_multiple_alerts_highest_severity(self): + """Should pick highest severity from multiple alerts.""" + analyzer = WeatherConditionAnalyzer() + alerts = [ + {"severity": "Minor", "event": "Wind Advisory"}, + {"severity": "Extreme", "event": "Tornado Emergency"}, + {"severity": "Moderate", "event": "Flood Watch"}, + ] + result = analyzer._analyze_alerts(alerts) + + assert result["alert_severity"] == WeatherSeverity.EXTREME + assert result["primary_alert"]["event"] == "Tornado Emergency" + + def test_unknown_alert_severity(self): + """Unknown severity should map to NORMAL.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer._map_alert_severity("Unknown") + assert result == WeatherSeverity.NORMAL + + def test_all_alert_severity_mappings(self): + """Test all alert severity string mappings.""" + analyzer = WeatherConditionAnalyzer() + + assert analyzer._map_alert_severity("Extreme") == WeatherSeverity.EXTREME + assert analyzer._map_alert_severity("Severe") == WeatherSeverity.SEVERE + assert analyzer._map_alert_severity("Moderate") == WeatherSeverity.MODERATE + assert analyzer._map_alert_severity("Minor") == WeatherSeverity.MINOR + + +class TestPriorityScoreCalculation: + """Test priority score calculation.""" + + def test_base_score_from_severity(self): + """Severity value * 10 contributes to score.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "severity": WeatherSeverity.SEVERE, # value = 3 + "temperature_extreme": None, + "wind_condition": None, + } + score = analyzer._calculate_priority_score(analysis) + assert score == 30 # 3 * 10 + + def test_extreme_temperature_bonus(self): + """Extreme temperatures add 50 points.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": "extreme_cold", + "wind_condition": None, + } + score = analyzer._calculate_priority_score(analysis) + assert score == 50 + + def test_very_temperature_bonus(self): + """Very cold/hot temperatures add 30 points.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": "very_hot", + "wind_condition": None, + } + score = analyzer._calculate_priority_score(analysis) + assert score == 30 + + def test_cold_hot_bonus(self): + """Cold/hot temperatures add 15 points.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": "cold", + "wind_condition": None, + } + score = analyzer._calculate_priority_score(analysis) + assert score == 15 + + def test_extreme_wind_bonus(self): + """Extreme wind adds 40 points.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": None, + "wind_condition": "extreme", + } + score = analyzer._calculate_priority_score(analysis) + assert score == 40 + + def test_very_strong_wind_bonus(self): + """Very strong wind adds 25 points.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": None, + "wind_condition": "very_strong", + } + score = analyzer._calculate_priority_score(analysis) + assert score == 25 + + def test_strong_wind_bonus(self): + """Strong wind adds 15 points.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": None, + "wind_condition": "strong", + } + score = analyzer._calculate_priority_score(analysis) + assert score == 15 + + def test_combined_score(self): + """Multiple factors combine correctly.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "severity": WeatherSeverity.MODERATE, # 20 + "temperature_extreme": "very_cold", # 30 + "wind_condition": "strong", # 15 + } + score = analyzer._calculate_priority_score(analysis) + assert score == 65 + + +class TestTemplateGeneration: + """Test format string template determination.""" + + def test_alert_template(self): + """Alerts should return alert template.""" + analyzer = WeatherConditionAnalyzer() + analysis = {"has_alerts": True} + template = analyzer._determine_template(analysis) + assert template == "alert" + + def test_severe_weather_template(self): + """Severe/extreme severity returns severe_weather template.""" + analyzer = WeatherConditionAnalyzer() + + for severity in [WeatherSeverity.SEVERE, WeatherSeverity.EXTREME]: + analysis = { + "has_alerts": False, + "severity": severity, + "temperature_extreme": None, + "wind_condition": None, + "category": ConditionCategory.CLEAR, + } + template = analyzer._determine_template(analysis) + assert template == "severe_weather" + + def test_temperature_extreme_template(self): + """Extreme/very temps return temperature_extreme template.""" + analyzer = WeatherConditionAnalyzer() + + for temp in ["extreme_cold", "extreme_hot", "very_cold", "very_hot"]: + analysis = { + "has_alerts": False, + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": temp, + "wind_condition": None, + "category": ConditionCategory.CLEAR, + } + template = analyzer._determine_template(analysis) + assert template == "temperature_extreme" + + def test_wind_warning_template(self): + """Strong winds return wind_warning template.""" + analyzer = WeatherConditionAnalyzer() + + for wind in ["extreme", "very_strong", "strong"]: + analysis = { + "has_alerts": False, + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": None, + "wind_condition": wind, + "category": ConditionCategory.CLEAR, + } + template = analyzer._determine_template(analysis) + assert template == "wind_warning" + + def test_precipitation_template(self): + """Precipitation/freezing categories return precipitation template.""" + analyzer = WeatherConditionAnalyzer() + + for category in [ConditionCategory.PRECIPITATION, ConditionCategory.FREEZING]: + analysis = { + "has_alerts": False, + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": None, + "wind_condition": None, + "category": category, + } + template = analyzer._determine_template(analysis) + assert template == "precipitation" + + def test_fog_template(self): + """Fog category returns fog template.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "has_alerts": False, + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": None, + "wind_condition": None, + "category": ConditionCategory.FOG, + } + template = analyzer._determine_template(analysis) + assert template == "fog" + + def test_default_template(self): + """Default conditions return default template.""" + analyzer = WeatherConditionAnalyzer() + analysis = { + "has_alerts": False, + "severity": WeatherSeverity.NORMAL, + "temperature_extreme": None, + "wind_condition": None, + "category": ConditionCategory.CLEAR, + } + template = analyzer._determine_template(analysis) + assert template == "default" + + +class TestAnalyzeWeatherConditions: + """Test main analysis function.""" + + def test_basic_clear_weather(self): + """Basic clear weather analysis.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer.analyze_weather_conditions({"weather_code": 0, "temp": 70}) + + assert result["category"] == ConditionCategory.CLEAR + assert result["severity"] == WeatherSeverity.NORMAL + assert result["recommended_template"] == "default" + assert result["has_alerts"] is False + + def test_alerts_take_priority(self): + """Alerts should override other conditions.""" + analyzer = WeatherConditionAnalyzer() + alerts = [{"severity": "Severe", "event": "Test Alert"}] + result = analyzer.analyze_weather_conditions( + {"weather_code": 0, "temp": 70}, + alerts_data=alerts + ) + + assert result["has_alerts"] is True + assert result["recommended_template"] == "alert" + assert result["priority_score"] == 1000 + + def test_weather_code_as_list(self): + """Should handle weather_code as list/tuple.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer.analyze_weather_conditions({"weather_code": [95, 96]}) + + assert result["primary_condition"] == 95 + assert result["category"] == ConditionCategory.THUNDERSTORM + + def test_unknown_weather_code_defaults(self): + """Unknown weather code defaults to CLEAR/NORMAL.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer.analyze_weather_conditions({"weather_code": 999}) + + assert result["category"] == ConditionCategory.CLEAR + assert result["severity"] == WeatherSeverity.NORMAL + + def test_exception_handling(self): + """Should handle exceptions gracefully.""" + analyzer = WeatherConditionAnalyzer() + # Pass something that would cause issues + result = analyzer.analyze_weather_conditions(None) + + assert "error" in result + assert result["recommended_template"] == "default" + + def test_full_analysis_with_all_conditions(self): + """Full analysis with temperature and wind.""" + analyzer = WeatherConditionAnalyzer() + result = analyzer.analyze_weather_conditions({ + "weather_code": 65, # Heavy rain + "temp": 40, + "wind_speed": 35, # Strong wind threshold + }) + + assert result["category"] == ConditionCategory.PRECIPITATION + assert result["severity"] == WeatherSeverity.SEVERE + assert result["temperature_extreme"] is None + assert result["wind_condition"] == "strong" + assert result["recommended_template"] == "severe_weather" + + +class TestEnums: + """Test enum values.""" + + def test_weather_severity_values(self): + """Severity enum has correct ordered values.""" + assert WeatherSeverity.NORMAL.value == 0 + assert WeatherSeverity.MINOR.value == 1 + assert WeatherSeverity.MODERATE.value == 2 + assert WeatherSeverity.SEVERE.value == 3 + assert WeatherSeverity.EXTREME.value == 4 + + def test_condition_category_values(self): + """Category enum has correct string values.""" + assert ConditionCategory.CLEAR.value == "clear" + assert ConditionCategory.CLOUDY.value == "cloudy" + assert ConditionCategory.PRECIPITATION.value == "precipitation" + assert ConditionCategory.SEVERE_WEATHER.value == "severe_weather" + assert ConditionCategory.FOG.value == "fog" + assert ConditionCategory.FREEZING.value == "freezing" + assert ConditionCategory.THUNDERSTORM.value == "thunderstorm" From d8ad7701d0f9ab728e41f601570bd2afbbb43063 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sun, 8 Feb 2026 04:47:43 +0000 Subject: [PATCH 2/6] fix: show nightly date instead of version in update dialog (#265) The update available dialog showed 'Current: 0.4.3' even when running a nightly build. Now shows the nightly date (e.g. 'Current: 20260205') when a build tag is present, matching the settings dialog behavior. --- src/accessiweather/ui/main_window.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/accessiweather/ui/main_window.py b/src/accessiweather/ui/main_window.py index 43b10c09..930ebfe4 100644 --- a/src/accessiweather/ui/main_window.py +++ b/src/accessiweather/ui/main_window.py @@ -429,6 +429,8 @@ def _on_check_updates(self) -> None: current_version = getattr(self.app, "version", "0.0.0") build_tag = getattr(self.app, "build_tag", None) current_nightly_date = parse_nightly_date(build_tag) if build_tag else None + # Show nightly date as the display version when running a nightly build + display_version = current_nightly_date if current_nightly_date else current_version # Show checking status wx.BeginBusyCursor() @@ -460,7 +462,7 @@ async def check(): elif current_nightly_date: msg = f"You're on the latest nightly ({current_nightly_date})." else: - msg = f"You're up to date ({current_version})." + msg = f"You're up to date ({display_version})." wx.CallAfter( wx.MessageBox, @@ -475,7 +477,7 @@ async def check(): def prompt(): result = wx.MessageBox( f"A new {channel_label} update is available!\n\n" - f"Current: {current_version}\n" + f"Current: {display_version}\n" f"Latest: {update_info.version}\n\n" "Download now?", "Update Available", From ae028daed25fd73758ec18382a6fce071393e6a0 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sun, 8 Feb 2026 04:49:18 +0000 Subject: [PATCH 3/6] fix: also fix nightly version display in startup update check The startup auto-update check in app.py had the same display bug showing '0.4.3' instead of the nightly date. --- src/accessiweather/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/accessiweather/app.py b/src/accessiweather/app.py index 1afee0e3..052c16ce 100644 --- a/src/accessiweather/app.py +++ b/src/accessiweather/app.py @@ -377,6 +377,7 @@ def do_check(): current_version = getattr(self, "version", "0.0.0") build_tag = getattr(self, "build_tag", None) current_nightly_date = parse_nightly_date(build_tag) if build_tag else None + display_version = current_nightly_date if current_nightly_date else current_version async def check(): service = UpdateService("AccessiWeather") @@ -399,7 +400,7 @@ async def check(): def show_update_notification(): result = wx.MessageBox( f"A new {channel_label} update is available!\n\n" - f"Current: {current_version}\n" + f"Current: {display_version}\n" f"Latest: {update_info.version}\n\n" "Download now?", "Update Available", From 3d3b06a3f5d53cb7b6e18947df97afe96f2a58ca Mon Sep 17 00:00:00 2001 From: Orinks Date: Sun, 8 Feb 2026 04:53:58 +0000 Subject: [PATCH 4/6] fix: prevent infinite update loop when build_tag is missing If a frozen build has no build_tag (e.g. _build_info.py failed to load) and the update channel is nightly, skip the startup auto-check. Without a build_tag, the comparison logic assumes any nightly is newer, causing a prompt loop on every restart. Users can still check manually via Help > Check for Updates. --- src/accessiweather/app.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/accessiweather/app.py b/src/accessiweather/app.py index 052c16ce..869488e5 100644 --- a/src/accessiweather/app.py +++ b/src/accessiweather/app.py @@ -379,6 +379,15 @@ def do_check(): current_nightly_date = parse_nightly_date(build_tag) if build_tag else None display_version = current_nightly_date if current_nightly_date else current_version + # Safety: if frozen but no build_tag and checking nightly channel, + # skip auto-prompt to avoid infinite update loops + if not build_tag and channel == "nightly": + logger.warning( + "Skipping startup nightly update check: no build_tag available. " + "Use Help > Check for Updates to check manually." + ) + return + async def check(): service = UpdateService("AccessiWeather") try: From 576f59bff2a955e0e6293e3a79a538f9655f15a8 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sun, 8 Feb 2026 04:56:50 +0000 Subject: [PATCH 5/6] fix: detect per-user installs via uninstaller presence (#265) is_portable_mode() only checked Program Files to detect installed copies. Per-user installs (e.g. %LOCALAPPDATA%\Programs) are writable and not under Program Files, so they were falsely detected as portable. This caused the updater to download the portable ZIP instead of the installer for per-user installs. Fix: check for Inno Setup uninstaller (unins*.exe) in the app directory before the Program Files check. This reliably detects installed copies regardless of install location. --- src/accessiweather/config_utils.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/accessiweather/config_utils.py b/src/accessiweather/config_utils.py index 585b1ceb..94947a2a 100644 --- a/src/accessiweather/config_utils.py +++ b/src/accessiweather/config_utils.py @@ -52,6 +52,20 @@ def is_portable_mode() -> bool: logger.debug(f"Checking portable mode for executable directory: {app_dir}") + # Check for uninstaller (Inno Setup leaves unins*.exe in app directory) + # This reliably detects installed copies regardless of install location + app_dir_path = os.path.dirname(sys.executable) + uninstaller_exists = any( + f.startswith("unins") and f.endswith(".exe") + for f in os.listdir(app_dir_path) + if os.path.isfile(os.path.join(app_dir_path, f)) + ) + if uninstaller_exists: + logger.debug( + f"Not in portable mode: uninstaller found in {app_dir_path}" + ) + return False + # Check if we're running from Program Files (standard installation) program_files = os.environ.get("PROGRAMFILES", "") program_files_x86 = os.environ.get("PROGRAMFILES(X86)", "") From 2c6419f1d304ff1f3570fcca9c35a0baecf48d56 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sun, 8 Feb 2026 05:10:09 +0000 Subject: [PATCH 6/6] feat: build.py auto-generates version and build info before building Local builds via installer/build.py now automatically run generate_version.py and generate_build_info.py before PyInstaller, matching what CI does. This prevents builds with missing BUILD_TAG. New flags: --nightly Build as nightly (auto-generates nightly-YYYYMMDD tag) --tag TAG Custom build tag (e.g. nightly-20260208) Usage: python installer/build.py --nightly # Local nightly build python installer/build.py # Stable build (BUILD_TAG=None) --- installer/build.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/installer/build.py b/installer/build.py index 91fbcb20..db3291e7 100644 --- a/installer/build.py +++ b/installer/build.py @@ -405,6 +405,40 @@ def run_dev() -> int: ).returncode +def generate_build_metadata(args: argparse.Namespace) -> None: + """Generate version and build info files (mirrors CI steps).""" + print("\n" + "=" * 60) + print("Generating build metadata...") + print("=" * 60 + "\n") + + # Generate version file + version_script = ROOT / "scripts" / "generate_version.py" + if version_script.exists(): + run_command([sys.executable, str(version_script)], cwd=ROOT) + else: + print(f"Warning: {version_script} not found, skipping version generation") + + # Generate build info (_build_info.py with BUILD_TAG) + build_info_script = ROOT / "scripts" / "generate_build_info.py" + if build_info_script.exists(): + tag = args.tag + if not tag and args.nightly: + from datetime import datetime, timezone + + tag = f"nightly-{datetime.now(timezone.utc).strftime('%Y%m%d')}" + + cmd = [sys.executable, str(build_info_script)] + if tag: + cmd.append(tag) + print(f" Build tag: {tag}") + else: + print(" Build tag: None (stable)") + + run_command(cmd, cwd=ROOT) + else: + print(f"Warning: {build_info_script} not found, skipping build info generation") + + def main() -> int: """Run the build process.""" parser = argparse.ArgumentParser( @@ -441,6 +475,17 @@ def main() -> int: action="store_true", help="Skip portable ZIP creation", ) + parser.add_argument( + "--nightly", + action="store_true", + help="Build as nightly (generates nightly-YYYYMMDD build tag)", + ) + parser.add_argument( + "--tag", + type=str, + default=None, + help="Custom build tag (e.g. nightly-20260208). Overrides --nightly.", + ) args = parser.parse_args() @@ -471,6 +516,9 @@ def main() -> int: if not args.skip_icons: check_icons() + # Generate version and build info (same as CI) + generate_build_metadata(args) + # Build with PyInstaller if not build_pyinstaller(): return 1