From 017d738675be7ebe37912072f375d3bfd9af4bd2 Mon Sep 17 00:00:00 2001 From: Gustavo Armelin Date: Fri, 14 Aug 2026 20:32:18 -0300 Subject: [PATCH] =?UTF-8?q?fix(alerts):=20n=C3=A3o=20disparar=20CRITICAL?= =?UTF-8?q?=20quando=20n=C3=A3o=20houver=20bateria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop ou ioreg vazio devolve percent=0 e design=0. O launchd a cada 15 min avisava para plugar o carregador. Sem DesignCapacity o comando agora sai quieto; MacBook em 0% real continua alertando. --- CHANGELOG.md | 4 +++ COMMANDS.md | 1 + macmanager/alerts.py | 10 +++++-- macmanager/battery.py | 4 +++ tests/test_alerts.py | 67 ++++++++++++++++++++++++++++++++++++++++++- tests/test_battery.py | 30 +++++++++++++++++++ 6 files changed, 113 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b14a7..598d1ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ - Version badge in README bumped to 0.1.1. ### Fixed +- **`mm alerts` não dispara mais CRITICAL em Mac sem bateria.** Desktop + ou `ioreg` vazio devolve percent=0 / design=0; o launchd a cada 15 min + avisava "plug in the charger". Sem `DesignCapacity` o comando agora + sai quieto. - **SSID, processo, serial e paths com `[` / `]` não crasham mais a UI.** Texto que vem do sistema passa por `safe_text` (escape do Rich) antes de entrar em markup — um Wi-Fi `My[/Home]WiFi` ou um processo diff --git a/COMMANDS.md b/COMMANDS.md index c610526..37703e2 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -216,6 +216,7 @@ mm alerts ``` > The state of the last notification is kept in `logs/.alert_state` (JSON with timestamps), preventing spam. +> Macs without a battery (desktop, or empty `ioreg`) produce no alerts. --- diff --git a/macmanager/alerts.py b/macmanager/alerts.py index f4afc1e..5b4b8aa 100644 --- a/macmanager/alerts.py +++ b/macmanager/alerts.py @@ -51,9 +51,15 @@ def _can_fire(state: dict, key: str, cooldown: int) -> bool: def check_and_alert() -> list[str]: - """Evaluates the battery state and fires notifications according to the rules. - Returns the list of fired keys (useful for debug/testing).""" + """Avalia a bateria e dispara notificações. Devolve as chaves disparadas. + + Sem bateria real (desktop / ioreg vazio) não notifica — percent=0 + nesses casos dispararia CRITICAL falso a cada 15 min via launchd. + """ bat = get_battery() + if not bat.is_present(): + return [] + state = _load_state() fired: list[str] = [] diff --git a/macmanager/battery.py b/macmanager/battery.py index 283c566..b78772e 100644 --- a/macmanager/battery.py +++ b/macmanager/battery.py @@ -28,6 +28,10 @@ class BatteryInfo: fully_charged: bool serial: Optional[str] + def is_present(self) -> bool: + """False em desktop ou ioreg vazio — DesignCapacity vem 0.""" + return self.design_capacity_mah > 0 + _IOREG_KEYS = ( "CycleCount", diff --git a/tests/test_alerts.py b/tests/test_alerts.py index a756288..2e72f30 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -12,7 +12,16 @@ import pytest -from macmanager.alerts import COOLDOWNS, CRITICAL, HEALTH_WARN, HIGH, LOW, _can_fire +from macmanager.alerts import ( + COOLDOWNS, + CRITICAL, + HEALTH_WARN, + HIGH, + LOW, + _can_fire, + check_and_alert, +) +from macmanager.battery import BatteryInfo class TestThresholdValues: @@ -87,3 +96,59 @@ def test_independent_keys_dont_interfere(self, monkeypatch: pytest.MonkeyPatch) assert _can_fire(state, "high", cooldown=60) is False assert _can_fire(state, "low", cooldown=60) is True assert _can_fire(state, "critical", cooldown=60) is True + + +def _battery(**overrides: object) -> BatteryInfo: + data: dict = { + "percent": 50.0, + "is_charging": False, + "power_source": "Battery Power", + "time_remaining_sec": None, + "cycle_count": 12, + "max_capacity_mah": 4300, + "design_capacity_mah": 4380, + "health_percent": 98.0, + "temperature_c": 30.0, + "fully_charged": False, + "serial": "C123", + } + data.update(overrides) + return BatteryInfo(**data) + + +class TestNoBatterySkipsAlerts: + """Desktop / ioreg vazio não pode virar spam de CRITICAL no launchd.""" + + def test_absent_battery_fires_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "macmanager.alerts.get_battery", + lambda: _battery( + percent=0.0, + design_capacity_mah=0, + max_capacity_mah=0, + health_percent=0.0, + cycle_count=0, + serial=None, + ), + ) + notified: list[str] = [] + monkeypatch.setattr( + "macmanager.alerts.notify", + lambda *args, **kwargs: notified.append(kwargs.get("title", "")), + ) + monkeypatch.setattr("macmanager.alerts._load_state", dict) + monkeypatch.setattr("macmanager.alerts._save_state", lambda state: None) + + assert check_and_alert() == [] + assert notified == [] + + def test_real_critical_battery_still_fires(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "macmanager.alerts.get_battery", + lambda: _battery(percent=5.0, is_charging=False), + ) + monkeypatch.setattr("macmanager.alerts.notify", lambda *args, **kwargs: None) + monkeypatch.setattr("macmanager.alerts._load_state", dict) + monkeypatch.setattr("macmanager.alerts._save_state", lambda state: None) + + assert check_and_alert() == ["critical"] diff --git a/tests/test_battery.py b/tests/test_battery.py index 971ca30..8240c32 100644 --- a/tests/test_battery.py +++ b/tests/test_battery.py @@ -55,3 +55,33 @@ def test_has_expected_fields(self) -> None: # Use subset: extra fields are fine (forward-compatible), missing # ones are a breaking change. assert expected.issubset(actual), f"missing fields: {expected - actual}" + + def test_is_present_requires_design_capacity(self) -> None: + absent = BatteryInfo( + percent=0.0, + is_charging=False, + power_source="Battery Power", + time_remaining_sec=None, + cycle_count=0, + max_capacity_mah=0, + design_capacity_mah=0, + health_percent=0.0, + temperature_c=None, + fully_charged=False, + serial=None, + ) + present = BatteryInfo( + percent=0.0, + is_charging=False, + power_source="Battery Power", + time_remaining_sec=None, + cycle_count=12, + max_capacity_mah=4300, + design_capacity_mah=4380, + health_percent=98.0, + temperature_c=30.0, + fully_charged=False, + serial="C123", + ) + assert absent.is_present() is False + assert present.is_present() is True