feat(notify): add toggle_per_crack_enabled runtime toggle

Promotes notify_per_crack_enabled from config-file-only to a runtime
toggle with the same style (global-decl, default-init, OSError-via-logger)
as the existing toggle_enabled, with full TDD coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-04-22 18:52:00 -04:00
co-authored by Claude Sonnet 4.6
parent 8a148d5261
commit e644e54b4b
2 changed files with 87 additions and 0 deletions
+20
View File
@@ -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
+67
View File
@@ -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()