feat(bal): Group D / D1 - configurable distributed calendar reminders + save-only .ics

D1 - number of reminders (default 3, max 5):
  - new NUM_REMINDERS config in plugin_base.py
  - new BalSpinBox widget bound to a BalConfig
  - new pure helper compute_reminder_offsets(days, count): reminders spread
    uniformly across the check-alive period, always before the deadline
    (offset >= 1), at most one per available day, de-duplicated, earliest
    first (e.g. (30,3)->[30,16,1], (2,3)->[2,1], (1,3)->[1], (0,3)->[])
  - create_alarms() rewritten to use it and emit one VALARM per offset with a
    DESCRIPTION reminder text
  - 'Number of reminders' spin box (range 1..5) added to the settings dialog
    and to the Reset list

D1b - save-only .ics (ask where, default to Desktop):
  - BalCalendar.desktop_dir() helper (~/Desktop with home fallback)
  - open_or_save_calendar() no longer opens the file with a calendar app; it
    always shows a 'save as' dialog (.ics filter, default will_event.ics,
    starting on the Desktop) and copies the file there, then confirms.
    Identical behaviour on Windows/Linux/macOS. save_to_cwd -> save_ics_to.

Follow-up: removed the now-unused 'Calendar App' field from the settings
  dialog (and from the Reset list). CALENDAR_APP config and
  open_with_default_app left in place (unused) to avoid unrelated changes.

D2 intentionally skipped (per user request).

tests/test_group_d_alarms.py: 7 new tests (NUM_REMINDERS default/change and
  all distribution rules). Full suite: 217 passed.
This commit is contained in:
2026-06-28 23:00:31 -04:00
parent 9d2cbc2814
commit b03251e864
6 changed files with 399 additions and 23 deletions

View File

@@ -0,0 +1,111 @@
"""
Tests for Group D / D1 (configurable, distributed calendar reminders).
Covered behaviour:
* the persisted ``NUM_REMINDERS`` configuration key exists and defaults to 3,
and can be changed and read back;
* ``compute_reminder_offsets(days, count)`` spreads the reminders across the
check-alive period, always BEFORE the deadline (every offset >= 1), uses at
most one reminder per available day, and never returns more reminders than
requested.
``compute_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
the other GUI tests.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_group_d_alarms.py -q
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig
from bal.gui.qt.widgets import compute_reminder_offsets
# ------------------------------------------------------------------ #
# Mocks
# ------------------------------------------------------------------ #
class FakeConfig:
"""Minimal mock for Electrum's config object (key/value store)."""
def __init__(self):
self._store = {}
def get(self, key, default=None):
return self._store.get(key, default)
def set_key(self, key, value, save=True):
self._store[key] = value
# ------------------------------------------------------------------ #
# NUM_REMINDERS config
# ------------------------------------------------------------------ #
def test_num_reminders_defaults_to_three():
"""D1: the reminder count defaults to 3."""
cfg = FakeConfig()
num = BalConfig(cfg, "bal_num_reminders", 3)
assert num.get() == 3
def test_num_reminders_can_be_changed():
"""D1: the reminder count is persisted and read back."""
cfg = FakeConfig()
num = BalConfig(cfg, "bal_num_reminders", 3)
num.set(5)
assert BalConfig(cfg, "bal_num_reminders", 3).get() == 5
# ------------------------------------------------------------------ #
# compute_reminder_offsets - distribution rules
# ------------------------------------------------------------------ #
def test_offsets_long_period_three_reminders():
"""30-day period, 3 reminders: spread out, all before the deadline."""
offsets = compute_reminder_offsets(30, 3)
assert len(offsets) == 3
# All strictly before the deadline (offset >= 1 means "n days before end").
assert all(o >= 1 for o in offsets)
# Sorted earliest-first (largest offset first).
assert offsets == sorted(offsets, reverse=True)
# One reminder near the start, one near the end.
assert max(offsets) == 30
assert min(offsets) == 1
def test_offsets_capped_at_one_per_day():
"""Short period: at most one reminder per available day."""
# 2 days but 3 requested -> only 2 reminders, one per day.
assert compute_reminder_offsets(2, 3) == [2, 1]
# 1 day but 3 requested -> a single reminder, the day before the deadline.
assert compute_reminder_offsets(1, 3) == [1]
def test_offsets_empty_when_no_room():
"""No reminders when there is no day before the deadline."""
assert compute_reminder_offsets(0, 3) == []
assert compute_reminder_offsets(-5, 3) == []
# A non-positive count also yields nothing.
assert compute_reminder_offsets(30, 0) == []
def test_offsets_never_exceed_requested_count():
"""The number of reminders never exceeds the requested count (max 5)."""
offsets = compute_reminder_offsets(100, 5)
assert len(offsets) == 5
assert all(o >= 1 for o in offsets)
# Distinct offsets only (no duplicate alarms on the same day).
assert len(set(offsets)) == len(offsets)
def test_offsets_single_reminder_is_day_before_deadline():
"""A single requested reminder fires one day before the deadline."""
assert compute_reminder_offsets(30, 1) == [1]