diff --git a/hate_crack/notify/__init__.py b/hate_crack/notify/__init__.py index 9f835e4..ea21ad3 100644 --- a/hate_crack/notify/__init__.py +++ b/hate_crack/notify/__init__.py @@ -49,6 +49,7 @@ from hate_crack.notify.settings import ( add_to_allowlist, load_settings, save_enabled, + save_per_crack_enabled, ) from hate_crack.notify.tailer import ( CrackTailer, @@ -91,6 +92,7 @@ __all__ = [ "stop_tailer", "suppressed_notifications", "toggle_enabled", + "toggle_per_crack_enabled", "_send_pushover", ] @@ -145,6 +147,24 @@ def toggle_enabled() -> bool: return _settings.enabled +def toggle_per_crack_enabled() -> bool: + """Flip ``notify_per_crack_enabled``, persist to ``config.json``, return new state. + + If ``init`` was never called we still toggle an in-memory default — the + UI update must not crash even if the config file is unreachable. + """ + global _settings + if _settings is None: + _settings = NotifySettings() + _settings.per_crack_enabled = not _settings.per_crack_enabled + if _config_path: + try: + save_per_crack_enabled(_config_path, _settings.per_crack_enabled) + except OSError as exc: + logger.warning("Could not persist notify_per_crack_enabled: %s", exc) + return _settings.per_crack_enabled + + def _in_allowlist(attack_name: str) -> bool: return attack_name in get_settings().attack_allowlist diff --git a/tests/test_notify_per_crack_toggle.py b/tests/test_notify_per_crack_toggle.py new file mode 100644 index 0000000..ee985ee --- /dev/null +++ b/tests/test_notify_per_crack_toggle.py @@ -0,0 +1,67 @@ +"""Unit tests for the toggle_per_crack_enabled runtime toggle.""" +import json +from pathlib import Path + +from hate_crack import notify as _notify + + +def _init_with(tmp_path: Path, **overrides) -> Path: + """Seed a config file with defaults + overrides and init the notify module.""" + config_path = tmp_path / "config.json" + cfg = { + "notify_enabled": False, + "notify_per_crack_enabled": False, + "notify_pushover_token": "", + "notify_pushover_user": "", + } + cfg.update(overrides) + config_path.write_text(json.dumps(cfg)) + _notify.init(str(config_path), cfg) + return config_path + + +class TestTogglePerCrackEnabled: + def test_off_to_on_flips_and_persists(self, tmp_path: Path) -> None: + config_path = _init_with(tmp_path) + try: + new_state = _notify.toggle_per_crack_enabled() + assert new_state is True + assert _notify.get_settings().per_crack_enabled is True + data = json.loads(config_path.read_text()) + assert data["notify_per_crack_enabled"] is True + finally: + _notify.clear_state_for_tests() + + def test_on_to_off_flips_and_persists(self, tmp_path: Path) -> None: + config_path = _init_with(tmp_path, notify_per_crack_enabled=True) + try: + new_state = _notify.toggle_per_crack_enabled() + assert new_state is False + assert _notify.get_settings().per_crack_enabled is False + data = json.loads(config_path.read_text()) + assert data["notify_per_crack_enabled"] is False + finally: + _notify.clear_state_for_tests() + + def test_toggle_without_init_uses_defaults(self) -> None: + # Mirrors the behavior of toggle_enabled: must not crash when init + # was never called. The toggle flips an in-memory default; nothing + # is persisted because _config_path is None. + try: + _notify.clear_state_for_tests() + new_state = _notify.toggle_per_crack_enabled() + assert new_state is True + assert _notify.get_settings().per_crack_enabled is True + finally: + _notify.clear_state_for_tests() + + def test_does_not_touch_global_enabled(self, tmp_path: Path) -> None: + config_path = _init_with(tmp_path, notify_enabled=False) + try: + _notify.toggle_per_crack_enabled() + data = json.loads(config_path.read_text()) + # notify_enabled stays False; only per-crack flipped. + assert data["notify_enabled"] is False + assert data["notify_per_crack_enabled"] is True + finally: + _notify.clear_state_for_tests()