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:
73
CHANGELOG.md
73
CHANGELOG.md
@@ -509,3 +509,76 @@ the will views, implemented together and delivered in a single ZIP.
|
|||||||
default font size like the others.
|
default font size like the others.
|
||||||
|
|
||||||
**Verification (third follow-up):** full suite `210 passed`.
|
**Verification (third follow-up):** full suite `210 passed`.
|
||||||
|
|
||||||
|
## 8. Group D / D1 - Configurable, distributed calendar reminders and save-only .ics
|
||||||
|
|
||||||
|
**Context / request:**
|
||||||
|
The exported calendar (.ics) used to add one reminder (VALARM) for every single
|
||||||
|
day of the check-alive period (potentially hundreds), and tried to open the file
|
||||||
|
with a calendar app. Group D D1 makes the number of reminders configurable
|
||||||
|
(default 3, max 5), spreads them across the period before the deadline, and
|
||||||
|
changes the calendar button to simply SAVE the .ics file (asking the user where,
|
||||||
|
starting on the Desktop) instead of opening it. (D2 was intentionally skipped.)
|
||||||
|
|
||||||
|
**What changed (D1 - number of reminders):**
|
||||||
|
|
||||||
|
- `bal/core/plugin_base.py`
|
||||||
|
- New persisted config `NUM_REMINDERS = BalConfig(config, "bal_num_reminders",
|
||||||
|
3)` (default 3).
|
||||||
|
|
||||||
|
- `bal/gui/qt/widgets.py`
|
||||||
|
- New `BalSpinBox` widget: an integer spin box bound to a `BalConfig`
|
||||||
|
(mirrors `BalCheckBox` / `BalLineEdit`), with a clamped range.
|
||||||
|
- New pure helper `compute_reminder_offsets(days, count)`: returns the
|
||||||
|
reminder offsets (in days before the deadline) spread uniformly across the
|
||||||
|
period. Every offset is >= 1 (reminders always fall before the deadline),
|
||||||
|
at most one reminder per available day (`min(count, days)`), de-duplicated,
|
||||||
|
sorted earliest-first. Examples: `(30, 3) -> [30, 16, 1]`,
|
||||||
|
`(2, 3) -> [2, 1]`, `(1, 3) -> [1]`, `(0, 3) -> []`.
|
||||||
|
- `create_alarms()` rewritten to read `NUM_REMINDERS`, use
|
||||||
|
`compute_reminder_offsets`, and emit one VALARM per offset (with a
|
||||||
|
DESCRIPTION reminder text, previously commented out).
|
||||||
|
|
||||||
|
- `bal/gui/qt/plugin.py`
|
||||||
|
- New "Number of reminders" spin box in the settings dialog (range 1..5,
|
||||||
|
default 3), with an explanatory tooltip, also included in the "Reset
|
||||||
|
setting" list (resets back to 3).
|
||||||
|
|
||||||
|
**What changed (D1b - save-only .ics, ask where, default to Desktop):**
|
||||||
|
|
||||||
|
- `bal/gui/qt/calendar.py`
|
||||||
|
- New `BalCalendar.desktop_dir()` helper returning the user's Desktop
|
||||||
|
(`~/Desktop` when present, otherwise the home directory).
|
||||||
|
|
||||||
|
- `bal/gui/qt/widgets.py`
|
||||||
|
- `open_or_save_calendar()` no longer tries to open the file with a calendar
|
||||||
|
app. It always opens a "save as" dialog (via `getSaveFileName`) with an
|
||||||
|
`.ics` filter, default filename `will_event.ics`, starting on the Desktop,
|
||||||
|
then copies the generated file to the chosen path and shows a confirmation
|
||||||
|
message. The unused `save_to_cwd` was replaced by `save_ics_to(target)`.
|
||||||
|
- The "Calendar App" setting is left in place (now unused) as requested.
|
||||||
|
|
||||||
|
- `tests/test_group_d_alarms.py` (new)
|
||||||
|
- Verifies `NUM_REMINDERS` default/change and all the distribution rules of
|
||||||
|
`compute_reminder_offsets` (spread, before-deadline, one-per-day cap, empty
|
||||||
|
when no room, never exceeding the requested count, single-reminder case).
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `ruff check` on changed files: no new errors (new test file is ruff-clean).
|
||||||
|
- Full test suite: `217 passed` (210 previous + 7 new Group D tests).
|
||||||
|
|
||||||
|
**Outcome:** DONE (delivered as a ZIP for user testing before commit).
|
||||||
|
|
||||||
|
### Group D - follow-up ("Calendar App" removed from settings)
|
||||||
|
|
||||||
|
- `bal/gui/qt/plugin.py`: removed the "Calendar App" field from the settings
|
||||||
|
dialog (and from the "Reset setting" list). Since the calendar button now only
|
||||||
|
SAVES the .ics file (it no longer opens it with an external app), the setting
|
||||||
|
was no longer needed. The `CALENDAR_APP` config and the `open_with_default_app`
|
||||||
|
helper are left in the codebase (unused, harmless) to avoid touching unrelated
|
||||||
|
code.
|
||||||
|
- The .ics "save as" behaviour is identical on Windows, Linux and macOS (always
|
||||||
|
starts on the user's Desktop, with a home-directory fallback); no per-OS
|
||||||
|
branching.
|
||||||
|
|
||||||
|
**Verification (follow-up):** full suite `217 passed`.
|
||||||
|
|||||||
@@ -181,6 +181,14 @@ class BalPlugin(BasePlugin):
|
|||||||
# stay display-only outside the wizard unless the user opts in.
|
# stay display-only outside the wizard unless the user opts in.
|
||||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||||
|
|
||||||
|
# NUM_REMINDERS (Group D / D1): how many reminder alarms (VALARM) the
|
||||||
|
# exported .ics calendar event should contain. The reminders are spread
|
||||||
|
# uniformly across the check-alive period and always fall BEFORE the
|
||||||
|
# delivery deadline. Default 3; the settings dialog caps it at 5 and the
|
||||||
|
# alarm builder additionally limits it to at most one alarm per available
|
||||||
|
# day.
|
||||||
|
self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3)
|
||||||
|
|
||||||
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
|
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
|
||||||
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
|
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
|
||||||
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ class BalCalendar:
|
|||||||
_logger.error(f"starting calendar app {e}")
|
_logger.error(f"starting calendar app {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def desktop_dir():
|
||||||
|
"""Return the user's Desktop directory (Group D / D1b).
|
||||||
|
|
||||||
|
Used as the initial folder of the "save .ics" dialog. On Windows this is
|
||||||
|
normally ``C:\\Users\\<name>\\Desktop``; on Linux/macOS ``~/Desktop`` is
|
||||||
|
used when it exists. If the Desktop cannot be located the home directory
|
||||||
|
is returned as a safe fallback, so the save dialog always opens
|
||||||
|
somewhere sensible.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An absolute directory path (string).
|
||||||
|
"""
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
desktop = os.path.join(home, "Desktop")
|
||||||
|
if os.path.isdir(desktop):
|
||||||
|
return desktop
|
||||||
|
return home
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_time(time):
|
def format_time(time):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from electrum.gui.qt.main_window import StatusBarButton
|
|||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .common import read_QIcon_from_bytes
|
from .common import read_QIcon_from_bytes
|
||||||
from .widgets import BalCheckBox, BalLineEdit, BalTextEdit
|
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
|
||||||
from .window import BalWindow
|
from .window import BalWindow
|
||||||
from .dialogs import BalDialog
|
from .dialogs import BalDialog
|
||||||
|
|
||||||
@@ -404,10 +404,17 @@ class Plugin(BalPlugin):
|
|||||||
# fields immediately become editable/read-only.
|
# fields immediately become editable/read-only.
|
||||||
heir_editable_dates = BalCheckBox(self.EDITABLE_DATES, on_multiverse_change)
|
heir_editable_dates = BalCheckBox(self.EDITABLE_DATES, on_multiverse_change)
|
||||||
|
|
||||||
|
# Number of reminders spin box (Group D / D1). Sets how many reminder
|
||||||
|
# alarms the exported .ics calendar event contains. Bound to the
|
||||||
|
# persisted NUM_REMINDERS config (default 3), with a range of 1..5.
|
||||||
|
heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5)
|
||||||
|
|
||||||
# Editable line/text widgets are created once and kept in named
|
# Editable line/text widgets are created once and kept in named
|
||||||
# variables so the "Reset" button (Group C / C4b) can refresh the
|
# variables so the "Reset" button (Group C / C4b) can refresh the
|
||||||
# displayed values after resetting the underlying config.
|
# displayed values after resetting the underlying config.
|
||||||
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
|
# Note: the "Calendar App" field was removed (Group D follow-up) because
|
||||||
|
# the calendar button now only SAVES the .ics file instead of opening it
|
||||||
|
# with an external app, so the setting is no longer needed in the dialog.
|
||||||
edit_event_summary = BalLineEdit(self.EVENT_SUMMARY)
|
edit_event_summary = BalLineEdit(self.EVENT_SUMMARY)
|
||||||
edit_event_description = BalTextEdit(self.EVENT_DESCRIPTION)
|
edit_event_description = BalTextEdit(self.EVENT_DESCRIPTION)
|
||||||
|
|
||||||
@@ -472,10 +479,17 @@ class Plugin(BalPlugin):
|
|||||||
)
|
)
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Calendar App",
|
"Number of reminders",
|
||||||
edit_calendar_app,
|
heir_num_reminders,
|
||||||
5,
|
5,
|
||||||
"Default app used to open calendar",
|
(
|
||||||
|
"How many reminder alarms the exported calendar (.ics) event "
|
||||||
|
"contains.\n"
|
||||||
|
"The reminders are spread across the check-alive period and "
|
||||||
|
"always fall before the delivery deadline.\n"
|
||||||
|
"If the period is shorter than the requested number, at most "
|
||||||
|
"one reminder per day is used. Range: 1 to 5 (default 3)."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
@@ -558,7 +572,7 @@ class Plugin(BalPlugin):
|
|||||||
(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"),
|
(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"),
|
||||||
(self.AUTO_SIGN, heir_auto_sign, "check"),
|
(self.AUTO_SIGN, heir_auto_sign, "check"),
|
||||||
(self.EDITABLE_DATES, heir_editable_dates, "check"),
|
(self.EDITABLE_DATES, heir_editable_dates, "check"),
|
||||||
(self.CALENDAR_APP, edit_calendar_app, "line"),
|
(self.NUM_REMINDERS, heir_num_reminders, "spin"),
|
||||||
(self.EVENT_SUMMARY, edit_event_summary, "line"),
|
(self.EVENT_SUMMARY, edit_event_summary, "line"),
|
||||||
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
|
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
|
||||||
]
|
]
|
||||||
@@ -570,6 +584,8 @@ class Plugin(BalPlugin):
|
|||||||
# value, which is harmless.
|
# value, which is harmless.
|
||||||
if kind == "check":
|
if kind == "check":
|
||||||
widget.setChecked(bool(cfg.default))
|
widget.setChecked(bool(cfg.default))
|
||||||
|
elif kind == "spin":
|
||||||
|
widget.setValue(int(cfg.default))
|
||||||
elif kind == "line":
|
elif kind == "line":
|
||||||
widget.setText(cfg.default)
|
widget.setText(cfg.default)
|
||||||
elif kind == "text":
|
elif kind == "text":
|
||||||
|
|||||||
@@ -23,6 +23,58 @@ from .common import _, _logger # underscore names are not re-exported by "impor
|
|||||||
from .calendar import BalCalendar
|
from .calendar import BalCalendar
|
||||||
|
|
||||||
|
|
||||||
|
def compute_reminder_offsets(days, count):
|
||||||
|
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
|
||||||
|
|
||||||
|
Group D / D1. The reminders are spread uniformly across the check-alive
|
||||||
|
period and always fall *before* the delivery deadline, i.e. every returned
|
||||||
|
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
* ``count`` is the requested number of reminders (the settings dialog
|
||||||
|
caps it at 5, default 3).
|
||||||
|
* at most ONE reminder per available day: the effective number is
|
||||||
|
``min(count, days)``;
|
||||||
|
* with ``days`` available days, offsets are chosen as evenly spaced
|
||||||
|
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
|
||||||
|
= the first day of the period), de-duplicated and returned sorted
|
||||||
|
descending (earliest reminder first).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: number of whole days between check-alive and the deadline.
|
||||||
|
count: requested number of reminders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
|
||||||
|
``days=30, count=3``. Empty if there is no room for any reminder.
|
||||||
|
"""
|
||||||
|
# No room for any reminder (deadline today or already passed).
|
||||||
|
if days < 1 or count < 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Never more reminders than available days (one per day at most).
|
||||||
|
effective = min(int(count), int(days))
|
||||||
|
|
||||||
|
# A single reminder: put it one day before the deadline.
|
||||||
|
if effective == 1:
|
||||||
|
return [1]
|
||||||
|
|
||||||
|
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
|
||||||
|
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
|
||||||
|
# This places the first reminder at the start of the period (offset ~days)
|
||||||
|
# and the last one one day before the deadline (offset 1).
|
||||||
|
offsets = set()
|
||||||
|
for i in range(effective):
|
||||||
|
frac = i / (effective - 1) # 0.0 .. 1.0
|
||||||
|
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
|
||||||
|
offset = round(days - frac * (days - 1))
|
||||||
|
offset = max(1, min(days, offset))
|
||||||
|
offsets.add(offset)
|
||||||
|
|
||||||
|
# Sorted descending: earliest reminder (largest offset) first.
|
||||||
|
return sorted(offsets, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
class ClickableLabel(QLabel):
|
class ClickableLabel(QLabel):
|
||||||
doubleClicked = pyqtSignal()
|
doubleClicked = pyqtSignal()
|
||||||
|
|
||||||
@@ -683,15 +735,45 @@ class WillSettingsWidget(QWidget):
|
|||||||
self.widgets["baltx_fees"].set_read_only(True)
|
self.widgets["baltx_fees"].set_read_only(True)
|
||||||
|
|
||||||
def create_alarms(self, alarm_start, alarm_end):
|
def create_alarms(self, alarm_start, alarm_end):
|
||||||
days = (alarm_end - alarm_start).days+1
|
"""Build the VALARM reminder blocks for the .ics event (Group D / D1).
|
||||||
|
|
||||||
|
The number of reminders is read from the NUM_REMINDERS setting (default
|
||||||
|
3, capped at 5 by the settings dialog). They are spread uniformly across
|
||||||
|
the check-alive period and always fall before the delivery deadline; if
|
||||||
|
the period is shorter than the requested number, at most one reminder
|
||||||
|
per day is produced (see compute_reminder_offsets).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
alarm_start: the check-alive datetime (start of the period).
|
||||||
|
alarm_end: the delivery-time / locktime datetime (the deadline).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of .ics text lines (possibly empty) describing the VALARMs.
|
||||||
|
"""
|
||||||
|
days = (alarm_end - alarm_start).days
|
||||||
|
|
||||||
|
# How many reminders the user asked for (default 3 if unreadable).
|
||||||
|
try:
|
||||||
|
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||||
|
except Exception:
|
||||||
|
count = 3
|
||||||
|
|
||||||
|
offsets = compute_reminder_offsets(days, count)
|
||||||
|
|
||||||
|
# Reminder text shown by the calendar app when each alarm fires.
|
||||||
|
description = _(
|
||||||
|
"BAL reminder: check in before your will is delivered to your heirs."
|
||||||
|
)
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for i in range(1, days):
|
for offset in offsets:
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
"BEGIN:VALARM",
|
"BEGIN:VALARM",
|
||||||
f"TRIGGER;RELATED=END:-P{i}D",
|
# Fire "offset" days before the event end (the deadline).
|
||||||
|
f"TRIGGER;RELATED=END:-P{offset}D",
|
||||||
"ACTION:DISPLAY",
|
"ACTION:DISPLAY",
|
||||||
# f"DESCRIPTION:{self.bal_window.bal_plugin.ALARM_DESCRIPTION.get()}",
|
f"DESCRIPTION:{BalCalendar.ical_escape(description)}",
|
||||||
"END:VALARM",
|
"END:VALARM",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -704,7 +786,6 @@ class WillSettingsWidget(QWidget):
|
|||||||
threshold = self.widgets["threshold"].alarm
|
threshold = self.widgets["threshold"].alarm
|
||||||
alarm_end = BalCalendar.format_time(locktime)
|
alarm_end = BalCalendar.format_time(locktime)
|
||||||
alarm_start = BalCalendar.format_time(threshold)
|
alarm_start = BalCalendar.format_time(threshold)
|
||||||
days_difference = (locktime - threshold).days
|
|
||||||
|
|
||||||
heirs_details = "\r\n".join(f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}" for heir in self.bal_window.heirs)
|
heirs_details = "\r\n".join(f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}" for heir in self.bal_window.heirs)
|
||||||
event_description = BalCalendar.ical_escape(
|
event_description = BalCalendar.ical_escape(
|
||||||
@@ -735,23 +816,54 @@ class WillSettingsWidget(QWidget):
|
|||||||
|
|
||||||
lines = [s.rstrip("\r\n") for s in lines]
|
lines = [s.rstrip("\r\n") for s in lines]
|
||||||
ics_content = "\r\n".join(lines) + "\r\n"
|
ics_content = "\r\n".join(lines) + "\r\n"
|
||||||
|
# Keep the generated .ics in a temp file; it is copied to the path the
|
||||||
|
# user picks below.
|
||||||
self.temp_path = BalCalendar.write_temp_ics(ics_content)
|
self.temp_path = BalCalendar.write_temp_ics(ics_content)
|
||||||
opened = BalCalendar.open_with_default_app(
|
|
||||||
self.bal_window.bal_plugin.CALENDAR_APP.get(), self.temp_path
|
|
||||||
)
|
|
||||||
if opened:
|
|
||||||
_logger.info(f"File opened with default app: {self.temp_path}")
|
|
||||||
else:
|
|
||||||
export_meta_gui(
|
|
||||||
self.bal_window.window, f"will_event.ics",self.save_to_cwd
|
|
||||||
|
|
||||||
|
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
||||||
|
# plugin no longer tries to open it with a calendar app). The save
|
||||||
|
# dialog opens on the user's Desktop by default, with an .ics filter and
|
||||||
|
# a sensible default filename.
|
||||||
|
desktop = BalCalendar.desktop_dir()
|
||||||
|
default_path = os.path.join(desktop, "will_event.ics")
|
||||||
|
target = getSaveFileName(
|
||||||
|
parent=self.bal_window.window,
|
||||||
|
title=_("Save calendar reminder (.ics)"),
|
||||||
|
# An absolute filename makes getSaveFileName ignore its remembered
|
||||||
|
# IO_DIRECTORY and open on the Desktop instead.
|
||||||
|
filename=default_path,
|
||||||
|
filter="iCalendar (*.ics);;All files (*)",
|
||||||
|
default_extension="ics",
|
||||||
|
config=self.bal_window.window.config,
|
||||||
|
)
|
||||||
|
if not target:
|
||||||
|
# User cancelled the save dialog: nothing to do.
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.save_ics_to(target)
|
||||||
|
except Exception as save_err:
|
||||||
|
_logger.error(f"saving .ics failed: {save_err}")
|
||||||
|
self.bal_window.show_warning(
|
||||||
|
_("Could not save the calendar file: {}").format(save_err)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.bal_window.show_message(
|
||||||
|
_("Calendar file saved to:\n{}").format(target)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def save_ics_to(self, target):
|
||||||
|
"""Copy the generated .ics from the temp file to ``target`` (Group D / D1b).
|
||||||
|
|
||||||
def save_to_cwd(self,filename="event.ics"):
|
Overwrites the destination if it already exists. Used by
|
||||||
target = os.path.abspath(filename)
|
open_or_save_calendar after the user picks a save location.
|
||||||
# se il file esiste, sovrascrive
|
|
||||||
_logger.debug(f"save_to_cwd {self.temp_path},{filename}")
|
Args:
|
||||||
|
target: absolute destination path chosen by the user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The destination path.
|
||||||
|
"""
|
||||||
|
_logger.debug(f"save_ics_to {self.temp_path} -> {target}")
|
||||||
with open(self.temp_path, "rb") as src, open(target, "wb") as dst:
|
with open(self.temp_path, "rb") as src, open(target, "wb") as dst:
|
||||||
dst.write(src.read())
|
dst.write(src.read())
|
||||||
return target
|
return target
|
||||||
@@ -884,6 +996,43 @@ class BalCheckBox(QCheckBox):
|
|||||||
self.stateChanged.connect(on_check)
|
self.stateChanged.connect(on_check)
|
||||||
|
|
||||||
|
|
||||||
|
class BalSpinBox(QSpinBox):
|
||||||
|
"""Integer spin box bound to a BalConfig value (Group D / D1).
|
||||||
|
|
||||||
|
Mirrors BalCheckBox / BalLineEdit: it shows the persisted value on creation
|
||||||
|
and writes the new value back to the config whenever the user changes it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
variable: the BalConfig accessor to read from / write to.
|
||||||
|
minimum: smallest selectable value (default 1).
|
||||||
|
maximum: largest selectable value (default 5, used by "Number of
|
||||||
|
reminders").
|
||||||
|
on_change: optional callback invoked after the value is persisted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, variable, minimum=1, maximum=5, on_change=None):
|
||||||
|
QSpinBox.__init__(self)
|
||||||
|
self.setMinimum(minimum)
|
||||||
|
self.setMaximum(maximum)
|
||||||
|
# Coerce the stored value to int and clamp it into the allowed range,
|
||||||
|
# so a stale/invalid config value can never push the spin box out of
|
||||||
|
# bounds.
|
||||||
|
try:
|
||||||
|
current = int(variable.get())
|
||||||
|
except Exception:
|
||||||
|
current = minimum
|
||||||
|
current = max(minimum, min(maximum, current))
|
||||||
|
self.setValue(current)
|
||||||
|
self.on_change = on_change
|
||||||
|
|
||||||
|
def on_value_changed(v):
|
||||||
|
variable.set(int(v))
|
||||||
|
if self.on_change:
|
||||||
|
self.on_change()
|
||||||
|
|
||||||
|
self.valueChanged.connect(on_value_changed)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class WillWidget(QWidget):
|
class WillWidget(QWidget):
|
||||||
def __init__(self, father=None, parent=None):
|
def __init__(self, father=None, parent=None):
|
||||||
|
|||||||
111
tests/test_group_d_alarms.py
Normal file
111
tests/test_group_d_alarms.py
Normal 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]
|
||||||
Reference in New Issue
Block a user