mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 13:23:13 +02:00
feat(screen-locker): add sick-day tracker and commitment debt flow
Adds a sick-day exemption flow with debt tracking so workout enforcement can be skipped on declared sick days while preserving phone-verification and shutdown invariants. - New _sick_tracker module persists sick_history.json (days, debt, commitments). - New _sick_dialog integrates declaration into the lock UI flow. - _ui_flows.py and screen_lock.py consult tracker before enforcing workouts. - gitignore sick_history.json (runtime state, like sick_day_state.json). - 304 tests pass; 100% branch coverage on every screen_locker file.
This commit is contained in:
parent
1d9eddb70e
commit
5aefaf7e45
@ -4,9 +4,29 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick
|
||||
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
|
||||
PHONE_PENALTY_DELAY_DEMO = 10
|
||||
PHONE_PENALTY_DELAY_PRODUCTION = 100
|
||||
# Penalty added to phone-penalty timer when ADB / phone unavailable
|
||||
# (so unplugging phone does not become an easy escape into sick mode).
|
||||
NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base
|
||||
# Sick day rate-limiting (rolling windows). Once any window is exhausted
|
||||
# the "I'm sick" button disappears entirely.
|
||||
SICK_BUDGET_PER_7_DAYS = 1
|
||||
SICK_BUDGET_PER_30_DAYS = 3
|
||||
SICK_BUDGET_PER_90_DAYS = 10
|
||||
# Each sick day in the trailing 30 days doubles the wait countdown.
|
||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2
|
||||
# Minimum chars in the freeform sick justification.
|
||||
SICK_JUSTIFICATION_MIN_CHARS = 120
|
||||
# How many past sick justifications to show on the dialog (read-only).
|
||||
SICK_HISTORY_REVIEW_COUNT = 10
|
||||
# Forced read-only delay before SUBMIT enables when a commitment was made.
|
||||
SICK_COMMITMENT_FORCED_READ_SECONDS = 5
|
||||
# Breaking a commitment counts as this many sick budget days.
|
||||
SICK_COMMITMENT_PENALTY_DAYS = 2
|
||||
# How long the commitment prompt stays visible after a workout unlock.
|
||||
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
|
||||
ADB_TIMEOUT = 15
|
||||
STRONGLIFTS_DB_REMOTE = (
|
||||
"/data/data/com.stronglifts.app/databases/StrongLifts-Database-3"
|
||||
@ -23,3 +43,6 @@ HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key")
|
||||
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
||||
# State file to track sick day usage and original config values
|
||||
SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
|
||||
# Persistent sick-day history (rate-limit, debt, commitments, justifications).
|
||||
# Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot.
|
||||
SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json"
|
||||
|
||||
292
screen_locker/_sick_dialog.py
Normal file
292
screen_locker/_sick_dialog.py
Normal file
@ -0,0 +1,292 @@
|
||||
"""Sick-day justification + commitment dialog mixin for the screen locker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.screen_locker import _sick_tracker
|
||||
from python_pkg.screen_locker._constants import (
|
||||
COMMITMENT_PROMPT_TIMEOUT_SECONDS,
|
||||
SICK_COMMITMENT_FORCED_READ_SECONDS,
|
||||
SICK_JUSTIFICATION_MIN_CHARS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from python_pkg.screen_locker._sick_tracker import SickHistory
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _disable_paste(widget: tk.Widget) -> None:
|
||||
"""Disable paste in a Tk Entry/Text widget.
|
||||
|
||||
Friction-only: a determined user can still bypass via xdotool, but the
|
||||
point is removing the trivial Ctrl+V shortcut so the user must
|
||||
actually type their justification.
|
||||
"""
|
||||
for sequence in ("<<Paste>>", "<Control-v>", "<Control-V>", "<Button-2>"):
|
||||
with contextlib.suppress(tk.TclError, AttributeError):
|
||||
widget.bind(sequence, lambda _e: "break")
|
||||
|
||||
|
||||
class SickDialogMixin:
|
||||
"""Renders the sick-day justification screen and commitment prompts."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sick-day justification dialog
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _show_sick_justification(self) -> None:
|
||||
"""Render the structured sick-day justification screen."""
|
||||
history = _sick_tracker.load_history()
|
||||
self._sick_history_cache: SickHistory = history
|
||||
self.clear_container()
|
||||
self._label("Sick Day Request", color="#cc6600", pady=10)
|
||||
self._text(_sick_tracker.budget_summary(history), color="#ffaa00")
|
||||
|
||||
recent = _sick_tracker.format_recent_justifications(history)
|
||||
if recent:
|
||||
self._text("Recent sick days:", font_size=14, color="#888888", pady=5)
|
||||
self._text(recent, font_size=14, color="#cccccc", pady=5)
|
||||
|
||||
had_commitment = _sick_tracker.had_commitment_for_today(history)
|
||||
if had_commitment:
|
||||
self._text(
|
||||
"⚠ Yesterday you committed to working out today.",
|
||||
font_size=18,
|
||||
color="#ff6666",
|
||||
)
|
||||
self._text(
|
||||
"Breaking the commitment costs 2 sick-budget days.",
|
||||
font_size=14,
|
||||
color="#ff6666",
|
||||
)
|
||||
|
||||
self._build_justification_form(had_commitment=had_commitment)
|
||||
|
||||
def _build_justification_form(self, *, had_commitment: bool) -> None:
|
||||
"""Add justification form fields and submit button to the container."""
|
||||
form = tk.Frame(self.container, bg="#1a1a1a")
|
||||
form.pack(pady=10)
|
||||
|
||||
self._sick_symptom_var = tk.StringVar()
|
||||
self._sick_onset_var = tk.StringVar()
|
||||
self._sick_severity_var = tk.IntVar(value=5)
|
||||
self._sick_text_widget = self._add_form_widgets(form)
|
||||
|
||||
self._sick_error_label = self._text("", color="#ff4444", pady=5)
|
||||
|
||||
button_row = self._button_row()
|
||||
self._sick_submit_button = self._button(
|
||||
button_row,
|
||||
"SUBMIT",
|
||||
bg="#666666",
|
||||
command=self._submit_sick_justification,
|
||||
width=12,
|
||||
)
|
||||
self._sick_submit_button.pack(side="left", padx=10)
|
||||
self._button(
|
||||
button_row,
|
||||
"BACK",
|
||||
bg="#aa0000",
|
||||
command=self._start_phone_check,
|
||||
width=12,
|
||||
).pack(side="left", padx=10)
|
||||
|
||||
if had_commitment:
|
||||
self._sick_submit_button.config(state="disabled")
|
||||
self._commitment_forced_remaining = SICK_COMMITMENT_FORCED_READ_SECONDS
|
||||
self._update_commitment_forced_delay()
|
||||
|
||||
def _add_form_widgets(self, parent: tk.Widget) -> tk.Text:
|
||||
"""Create symptom/onset/severity/text widgets. Returns the text widget."""
|
||||
self._add_label_entry(
|
||||
parent,
|
||||
label="Symptom (e.g. fever, nausea):",
|
||||
variable=self._sick_symptom_var,
|
||||
)
|
||||
self._add_label_entry(
|
||||
parent,
|
||||
label="When did it start? (e.g. last night):",
|
||||
variable=self._sick_onset_var,
|
||||
)
|
||||
sev_row = tk.Frame(parent, bg="#1a1a1a")
|
||||
sev_row.pack(pady=5)
|
||||
tk.Label(
|
||||
sev_row,
|
||||
text="Severity (1-10):",
|
||||
font=("Arial", 14),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
).pack(side="left", padx=5)
|
||||
tk.Spinbox(
|
||||
sev_row,
|
||||
from_=1,
|
||||
to=10,
|
||||
textvariable=self._sick_severity_var,
|
||||
width=4,
|
||||
font=("Arial", 14),
|
||||
).pack(side="left", padx=5)
|
||||
|
||||
tk.Label(
|
||||
parent,
|
||||
text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"),
|
||||
font=("Arial", 14),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
).pack(pady=5)
|
||||
text_widget = tk.Text(
|
||||
parent,
|
||||
width=60,
|
||||
height=6,
|
||||
font=("Arial", 12),
|
||||
bg="#2a2a2a",
|
||||
fg="white",
|
||||
insertbackground="white",
|
||||
)
|
||||
text_widget.pack(pady=5)
|
||||
_disable_paste(text_widget)
|
||||
return text_widget
|
||||
|
||||
def _add_label_entry(
|
||||
self,
|
||||
parent: tk.Widget,
|
||||
*,
|
||||
label: str,
|
||||
variable: tk.StringVar,
|
||||
) -> None:
|
||||
"""Add a label + single-line entry pair, with paste disabled."""
|
||||
row = tk.Frame(parent, bg="#1a1a1a")
|
||||
row.pack(pady=5, fill="x")
|
||||
tk.Label(
|
||||
row,
|
||||
text=label,
|
||||
font=("Arial", 14),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
anchor="w",
|
||||
).pack(side="top", anchor="w")
|
||||
entry = tk.Entry(
|
||||
row,
|
||||
textvariable=variable,
|
||||
width=50,
|
||||
font=("Arial", 14),
|
||||
bg="#2a2a2a",
|
||||
fg="white",
|
||||
insertbackground="white",
|
||||
)
|
||||
entry.pack(side="top", anchor="w", pady=2)
|
||||
_disable_paste(entry)
|
||||
|
||||
def _update_commitment_forced_delay(self) -> None:
|
||||
"""Tick down the forced-read delay then enable the submit button."""
|
||||
if self._commitment_forced_remaining > 0:
|
||||
self._sick_submit_button.config(
|
||||
text=f"WAIT {self._commitment_forced_remaining}s",
|
||||
)
|
||||
self._commitment_forced_remaining -= 1
|
||||
self.root.after(1000, self._update_commitment_forced_delay)
|
||||
else:
|
||||
self._sick_submit_button.config(text="SUBMIT", state="normal")
|
||||
|
||||
def _submit_sick_justification(self) -> None:
|
||||
"""Validate the form and either show an error or proceed to countdown."""
|
||||
symptom = self._sick_symptom_var.get()
|
||||
onset = self._sick_onset_var.get()
|
||||
try:
|
||||
severity = int(self._sick_severity_var.get())
|
||||
except (tk.TclError, ValueError):
|
||||
severity = 0
|
||||
text = self._sick_text_widget.get("1.0", "end").strip()
|
||||
draft = _sick_tracker.JustificationDraft(
|
||||
symptom=symptom,
|
||||
onset=onset,
|
||||
severity=severity,
|
||||
text=text,
|
||||
)
|
||||
error = _sick_tracker.validate_justification(draft)
|
||||
if error is not None:
|
||||
self._sick_error_label.config(text=error)
|
||||
return
|
||||
|
||||
history = self._sick_history_cache
|
||||
_sick_tracker.add_justification(history, draft)
|
||||
if not _sick_tracker.save_history(history):
|
||||
self._sick_error_label.config(
|
||||
text="Could not persist sick history — try again",
|
||||
)
|
||||
return
|
||||
self._proceed_to_sick_countdown()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Commitment prompt (after a verified workout)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _show_commitment_prompt(self, *, on_done: Callable[[], None]) -> None:
|
||||
"""Ask the user to commit to working out tomorrow.
|
||||
|
||||
Calls ``on_done()`` once the user answers or the timeout elapses.
|
||||
"""
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"Commit to working out tomorrow?",
|
||||
font_size=32,
|
||||
color="#ffaa00",
|
||||
pady=20,
|
||||
)
|
||||
self._text(
|
||||
"If you say YES and skip via 'I'm sick' tomorrow, "
|
||||
"the sick day costs 2x normal.",
|
||||
font_size=16,
|
||||
)
|
||||
self._commitment_done_fn = on_done
|
||||
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
|
||||
self._commitment_timer_label = self._text(
|
||||
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
|
||||
color="#888888",
|
||||
)
|
||||
row = self._button_row()
|
||||
self._button(
|
||||
row,
|
||||
"YES",
|
||||
bg="#00aa00",
|
||||
command=lambda: self._answer_commitment(commit=True),
|
||||
width=12,
|
||||
).pack(side="left", padx=10)
|
||||
self._button(
|
||||
row,
|
||||
"NO",
|
||||
bg="#aa0000",
|
||||
command=lambda: self._answer_commitment(commit=False),
|
||||
width=12,
|
||||
).pack(side="left", padx=10)
|
||||
self._tick_commitment_timeout()
|
||||
|
||||
def _tick_commitment_timeout(self) -> None:
|
||||
"""Advance commitment auto-skip timer; default to NO when it expires."""
|
||||
if self._commitment_remaining <= 0:
|
||||
self._answer_commitment(commit=False)
|
||||
return
|
||||
self._commitment_timer_label.config(
|
||||
text=f"Auto-skipping in {self._commitment_remaining}s",
|
||||
)
|
||||
self._commitment_remaining -= 1
|
||||
self.root.after(1000, self._tick_commitment_timeout)
|
||||
|
||||
def _answer_commitment(self, *, commit: bool) -> None:
|
||||
"""Persist the commitment answer and call the completion callback."""
|
||||
# Disable timer re-entry by zeroing remaining.
|
||||
self._commitment_remaining = -1
|
||||
if commit:
|
||||
history = _sick_tracker.load_history()
|
||||
_sick_tracker.record_commitment_for_tomorrow(history)
|
||||
_sick_tracker.save_history(history)
|
||||
done = getattr(self, "_commitment_done_fn", None)
|
||||
if done is not None:
|
||||
self._commitment_done_fn = None
|
||||
done()
|
||||
304
screen_locker/_sick_tracker.py
Normal file
304
screen_locker/_sick_tracker.py
Normal file
@ -0,0 +1,304 @@
|
||||
"""Sick-day rate-limiting, workout debt, commitment, and justification tracking.
|
||||
|
||||
Pure logic — no Tk imports. The UI calls into these helpers and persists
|
||||
state via :func:`save_history`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from python_pkg.screen_locker._constants import (
|
||||
SICK_BUDGET_PER_7_DAYS,
|
||||
SICK_BUDGET_PER_30_DAYS,
|
||||
SICK_BUDGET_PER_90_DAYS,
|
||||
SICK_COMMITMENT_PENALTY_DAYS,
|
||||
SICK_HISTORY_FILE,
|
||||
SICK_HISTORY_REVIEW_COUNT,
|
||||
SICK_JUSTIFICATION_MIN_CHARS,
|
||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
|
||||
SICK_LOCKOUT_SECONDS,
|
||||
)
|
||||
from python_pkg.shared.log_integrity import compute_entry_hmac
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SickHistory:
|
||||
"""Persistent sick-day bookkeeping."""
|
||||
|
||||
sick_days: list[str] = field(default_factory=list)
|
||||
debt: int = 0
|
||||
commitments: dict[str, bool] = field(default_factory=dict)
|
||||
broken_commitments: list[str] = field(default_factory=list)
|
||||
justifications: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
"""Return today's date as ``YYYY-MM-DD`` (UTC)."""
|
||||
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _parse_iso(date_str: str) -> datetime | None:
|
||||
"""Parse ``YYYY-MM-DD`` into a UTC datetime, or return None."""
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def load_history() -> SickHistory:
|
||||
"""Read the persistent sick-day history file.
|
||||
|
||||
Missing or unreadable files yield an empty :class:`SickHistory`.
|
||||
"""
|
||||
if not SICK_HISTORY_FILE.exists():
|
||||
return SickHistory()
|
||||
try:
|
||||
with SICK_HISTORY_FILE.open() as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
_logger.warning("Could not read sick history; starting fresh")
|
||||
return SickHistory()
|
||||
return SickHistory(
|
||||
sick_days=list(data.get("sick_days", [])),
|
||||
debt=int(data.get("debt", 0)),
|
||||
commitments=dict(data.get("commitments", {})),
|
||||
broken_commitments=list(data.get("broken_commitments", [])),
|
||||
justifications=list(data.get("justifications", [])),
|
||||
)
|
||||
|
||||
|
||||
def save_history(history: SickHistory) -> bool:
|
||||
"""Persist ``history``. Returns True on success."""
|
||||
payload = {
|
||||
"sick_days": history.sick_days,
|
||||
"debt": history.debt,
|
||||
"commitments": history.commitments,
|
||||
"broken_commitments": history.broken_commitments,
|
||||
"justifications": history.justifications,
|
||||
}
|
||||
try:
|
||||
with SICK_HISTORY_FILE.open("w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
except OSError as exc:
|
||||
_logger.warning("Failed to save sick history: %s", exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def count_in_window(
|
||||
history: SickHistory,
|
||||
days: int,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> int:
|
||||
"""Return how many ``sick_days`` fall in the trailing ``days`` window."""
|
||||
today_str = today or _today_iso()
|
||||
today_dt = _parse_iso(today_str)
|
||||
if today_dt is None:
|
||||
return 0
|
||||
cutoff = today_dt - timedelta(days=days)
|
||||
count = 0
|
||||
for entry in history.sick_days:
|
||||
parsed = _parse_iso(entry)
|
||||
if parsed is None:
|
||||
continue
|
||||
if cutoff < parsed <= today_dt:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def is_budget_exhausted(
|
||||
history: SickHistory,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> bool:
|
||||
"""Return True if any rolling window has reached its sick budget."""
|
||||
return (
|
||||
count_in_window(history, 7, today=today) >= SICK_BUDGET_PER_7_DAYS
|
||||
or count_in_window(history, 30, today=today) >= SICK_BUDGET_PER_30_DAYS
|
||||
or count_in_window(history, 90, today=today) >= SICK_BUDGET_PER_90_DAYS
|
||||
)
|
||||
|
||||
|
||||
def compute_lockout_seconds(
|
||||
history: SickHistory,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> int:
|
||||
"""Escalating sick countdown: ``base * 2 ** recent_count_in_30d``."""
|
||||
recent = count_in_window(history, 30, today=today)
|
||||
multiplier = SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent
|
||||
return SICK_LOCKOUT_SECONDS * multiplier
|
||||
|
||||
|
||||
def budget_summary(
|
||||
history: SickHistory,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> str:
|
||||
"""One-line UI summary string for budget + debt."""
|
||||
week = count_in_window(history, 7, today=today)
|
||||
month = count_in_window(history, 30, today=today)
|
||||
quarter = count_in_window(history, 90, today=today)
|
||||
return (
|
||||
f"Sick: {week}/{SICK_BUDGET_PER_7_DAYS}w · "
|
||||
f"{month}/{SICK_BUDGET_PER_30_DAYS}m · "
|
||||
f"{quarter}/{SICK_BUDGET_PER_90_DAYS}q · "
|
||||
f"Debt: {history.debt}"
|
||||
)
|
||||
|
||||
|
||||
def add_sick_day(history: SickHistory, *, today: str | None = None) -> int:
|
||||
"""Append today's date and increment debt. Returns new debt.
|
||||
|
||||
If today appears in ``broken_commitments`` the debt grows by
|
||||
:data:`SICK_COMMITMENT_PENALTY_DAYS` instead of 1.
|
||||
"""
|
||||
today_str = today or _today_iso()
|
||||
if today_str not in history.sick_days:
|
||||
history.sick_days.append(today_str)
|
||||
increment = (
|
||||
SICK_COMMITMENT_PENALTY_DAYS if today_str in history.broken_commitments else 1
|
||||
)
|
||||
history.debt += increment
|
||||
return history.debt
|
||||
|
||||
|
||||
def clear_one_debt(history: SickHistory) -> int:
|
||||
"""Decrement debt by one (clamped at zero). Returns new debt."""
|
||||
if history.debt > 0:
|
||||
history.debt -= 1
|
||||
return history.debt
|
||||
|
||||
|
||||
def record_commitment_for_tomorrow(
|
||||
history: SickHistory,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> str:
|
||||
"""Record that the user committed to working out tomorrow.
|
||||
|
||||
Returns the ISO date for tomorrow.
|
||||
"""
|
||||
today_str = today or _today_iso()
|
||||
today_dt = _parse_iso(today_str)
|
||||
if today_dt is None:
|
||||
return today_str
|
||||
tomorrow = (today_dt + timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
history.commitments[tomorrow] = True
|
||||
return tomorrow
|
||||
|
||||
|
||||
def had_commitment_for_today(
|
||||
history: SickHistory,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> bool:
|
||||
"""Return True if a commitment exists for today."""
|
||||
today_str = today or _today_iso()
|
||||
return bool(history.commitments.get(today_str, False))
|
||||
|
||||
|
||||
def mark_commitment_broken(
|
||||
history: SickHistory,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> None:
|
||||
"""Mark today's commitment as broken (idempotent)."""
|
||||
today_str = today or _today_iso()
|
||||
if today_str in history.commitments and today_str not in history.broken_commitments:
|
||||
history.broken_commitments.append(today_str)
|
||||
|
||||
|
||||
SICK_SEVERITY_MIN = 1
|
||||
SICK_SEVERITY_MAX = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class JustificationDraft:
|
||||
"""User-supplied justification fields for a sick-day request."""
|
||||
|
||||
symptom: str
|
||||
onset: str
|
||||
severity: int
|
||||
text: str
|
||||
|
||||
|
||||
def validate_justification(draft: JustificationDraft) -> str | None:
|
||||
"""Return an error message if the justification is invalid, else None."""
|
||||
if not draft.symptom.strip():
|
||||
return "Symptom is required"
|
||||
if not draft.onset.strip():
|
||||
return "Onset time is required"
|
||||
if not SICK_SEVERITY_MIN <= draft.severity <= SICK_SEVERITY_MAX:
|
||||
return f"Severity must be between {SICK_SEVERITY_MIN} and {SICK_SEVERITY_MAX}"
|
||||
if len(draft.text.strip()) < SICK_JUSTIFICATION_MIN_CHARS:
|
||||
return (
|
||||
f"Description must be at least "
|
||||
f"{SICK_JUSTIFICATION_MIN_CHARS} characters "
|
||||
f"(currently {len(draft.text.strip())})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def add_justification(
|
||||
history: SickHistory,
|
||||
draft: JustificationDraft,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""HMAC-sign and append a sick-day justification.
|
||||
|
||||
Returns the stored entry (with ``hmac`` field if a key was available).
|
||||
"""
|
||||
today_str = today or _today_iso()
|
||||
entry: dict[str, Any] = {
|
||||
"date": today_str,
|
||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"symptom": draft.symptom.strip(),
|
||||
"onset": draft.onset.strip(),
|
||||
"severity": int(draft.severity),
|
||||
"text": draft.text.strip(),
|
||||
}
|
||||
signature = compute_entry_hmac(entry)
|
||||
if signature is not None:
|
||||
entry["hmac"] = signature
|
||||
history.justifications.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def recent_justifications(
|
||||
history: SickHistory,
|
||||
n: int = SICK_HISTORY_REVIEW_COUNT,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the last ``n`` justifications (oldest first)."""
|
||||
if n <= 0:
|
||||
return []
|
||||
return list(history.justifications[-n:])
|
||||
|
||||
|
||||
def format_recent_justifications(
|
||||
history: SickHistory,
|
||||
n: int = SICK_HISTORY_REVIEW_COUNT,
|
||||
) -> str:
|
||||
"""Human-readable multi-line summary of recent justifications.
|
||||
|
||||
Empty string when there are no past entries.
|
||||
"""
|
||||
entries = recent_justifications(history, n)
|
||||
if not entries:
|
||||
return ""
|
||||
lines: list[str] = []
|
||||
for entry in entries:
|
||||
date_str = entry.get("date", "?")
|
||||
symptom = entry.get("symptom", "?")
|
||||
severity = entry.get("severity", "?")
|
||||
lines.append(f"{date_str} sev {severity}/10 — {symptom}")
|
||||
return "\n".join(lines)
|
||||
@ -5,10 +5,11 @@ from __future__ import annotations
|
||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.screen_locker import _sick_tracker
|
||||
from python_pkg.screen_locker._constants import (
|
||||
NO_PHONE_EXTRA_LOCKOUT_SECONDS,
|
||||
PHONE_PENALTY_DELAY_DEMO,
|
||||
PHONE_PENALTY_DELAY_PRODUCTION,
|
||||
SICK_LOCKOUT_SECONDS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -37,10 +38,12 @@ class UIFlowsMixin:
|
||||
self.root.after(500, self._poll_phone_check)
|
||||
|
||||
def _show_retry_and_sick(self, message: str) -> None:
|
||||
"""Show TRY AGAIN and I'm sick buttons after a failed phone check."""
|
||||
"""Show TRY AGAIN and (if budget allows) I'm sick after a failed check."""
|
||||
self.clear_container()
|
||||
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
|
||||
self._text(message, color="#ffaa00")
|
||||
history = _sick_tracker.load_history()
|
||||
self._text(_sick_tracker.budget_summary(history), color="#888888")
|
||||
frame = self._button_row()
|
||||
self._button(
|
||||
frame,
|
||||
@ -49,13 +52,19 @@ class UIFlowsMixin:
|
||||
command=self._start_phone_check,
|
||||
width=12,
|
||||
).pack(side="left", padx=10)
|
||||
self._button(
|
||||
frame,
|
||||
"I'm sick",
|
||||
bg="#cc6600",
|
||||
command=self.ask_if_sick,
|
||||
width=12,
|
||||
).pack(side="left", padx=10)
|
||||
if _sick_tracker.is_budget_exhausted(history):
|
||||
self._text(
|
||||
"Sick budget exhausted. No 'I'm sick' option available.",
|
||||
color="#ff6666",
|
||||
)
|
||||
else:
|
||||
self._button(
|
||||
frame,
|
||||
"I'm sick",
|
||||
bg="#cc6600",
|
||||
command=self.ask_if_sick,
|
||||
width=12,
|
||||
).pack(side="left", padx=10)
|
||||
|
||||
def _handle_startup_phone_result(self, status: str, message: str) -> None:
|
||||
"""Route to appropriate screen based on startup phone check result."""
|
||||
@ -98,32 +107,8 @@ class UIFlowsMixin:
|
||||
self._show_phone_penalty(message)
|
||||
|
||||
def ask_if_sick(self) -> None:
|
||||
"""Display sick day question dialog."""
|
||||
self.clear_container()
|
||||
self._label("Are you sick?", pady=30)
|
||||
self._text(
|
||||
"If yes, shutdown time will be moved 1.5 hours earlier",
|
||||
color="#ffaa00",
|
||||
)
|
||||
self._sick_question_buttons()
|
||||
|
||||
def _sick_question_buttons(self) -> None:
|
||||
"""Create the sick day yes/no buttons."""
|
||||
frame = self._button_row()
|
||||
self._button(
|
||||
frame,
|
||||
"YES (sick)",
|
||||
bg="#cc6600",
|
||||
command=self.handle_sick_day,
|
||||
width=12,
|
||||
).pack(side="left", padx=20)
|
||||
self._button(
|
||||
frame,
|
||||
"NO",
|
||||
bg="#aa0000",
|
||||
command=self.lockout,
|
||||
width=12,
|
||||
).pack(side="left", padx=20)
|
||||
"""Display the structured sick-day justification dialog."""
|
||||
self._show_sick_justification()
|
||||
|
||||
def _get_sick_day_status(self) -> tuple[str, str]:
|
||||
"""Determine sick day status text and color."""
|
||||
@ -135,25 +120,40 @@ class UIFlowsMixin:
|
||||
), "#00aa00"
|
||||
return "Could not adjust shutdown time (check permissions)", "#ff4444"
|
||||
|
||||
def handle_sick_day(self) -> None:
|
||||
"""Handle sick day: adjust shutdown time and start 2-minute wait."""
|
||||
def _proceed_to_sick_countdown(self) -> None:
|
||||
"""Start the (escalated) sick day countdown after justification."""
|
||||
history = getattr(
|
||||
self,
|
||||
"_sick_history_cache",
|
||||
None,
|
||||
)
|
||||
if history is None:
|
||||
history = _sick_tracker.load_history()
|
||||
self._sick_history_cache = history
|
||||
countdown = _sick_tracker.compute_lockout_seconds(history)
|
||||
self.clear_container()
|
||||
status_text, status_color = self._get_sick_day_status()
|
||||
self._show_sick_day_ui(status_text, status_color)
|
||||
self.sick_remaining_time = SICK_LOCKOUT_SECONDS
|
||||
self._show_sick_day_ui(status_text, status_color, countdown)
|
||||
self.sick_remaining_time = countdown
|
||||
self._update_sick_countdown()
|
||||
|
||||
def _show_sick_day_ui(self, status_text: str, status_color: str) -> None:
|
||||
def _show_sick_day_ui(
|
||||
self,
|
||||
status_text: str,
|
||||
status_color: str,
|
||||
countdown: int,
|
||||
) -> None:
|
||||
"""Display sick day UI labels and countdown."""
|
||||
self._label("Sick Day Mode", color="#cc6600", pady=20)
|
||||
self._text(status_text, color=status_color)
|
||||
minutes = countdown // 60
|
||||
self._text(
|
||||
"Please wait 2 minutes before unlocking...",
|
||||
f"Please wait ~{minutes} min before unlocking...",
|
||||
font_size=24,
|
||||
pady=20,
|
||||
)
|
||||
self.sick_countdown_label = self._label(
|
||||
str(SICK_LOCKOUT_SECONDS),
|
||||
str(countdown),
|
||||
font_size=80,
|
||||
pady=30,
|
||||
)
|
||||
@ -165,10 +165,22 @@ class UIFlowsMixin:
|
||||
self.sick_remaining_time -= 1
|
||||
self.root.after(1000, self._update_sick_countdown)
|
||||
else:
|
||||
# Record sick day and unlock
|
||||
self.workout_data["type"] = "sick_day"
|
||||
self.workout_data["note"] = "Sick day - shutdown moved earlier"
|
||||
self.unlock_screen()
|
||||
self._finalize_sick_day()
|
||||
|
||||
def _finalize_sick_day(self) -> None:
|
||||
"""Persist sick-day history and unlock the screen."""
|
||||
history = getattr(self, "_sick_history_cache", None)
|
||||
if history is None:
|
||||
history = _sick_tracker.load_history()
|
||||
if _sick_tracker.had_commitment_for_today(history):
|
||||
_sick_tracker.mark_commitment_broken(history)
|
||||
self.workout_data["broke_commitment"] = "true"
|
||||
new_debt = _sick_tracker.add_sick_day(history)
|
||||
_sick_tracker.save_history(history)
|
||||
self.workout_data["type"] = "sick_day"
|
||||
self.workout_data["note"] = "Sick day - shutdown moved earlier"
|
||||
self.workout_data["debt"] = str(new_debt)
|
||||
self.unlock_screen()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lockout flow
|
||||
@ -214,11 +226,17 @@ class UIFlowsMixin:
|
||||
if on_done is not None
|
||||
else lambda: self._show_retry_and_sick(message)
|
||||
)
|
||||
delay = (
|
||||
base_delay = (
|
||||
PHONE_PENALTY_DELAY_DEMO
|
||||
if self.demo_mode
|
||||
else PHONE_PENALTY_DELAY_PRODUCTION
|
||||
)
|
||||
# Disconnecting the phone shouldn't be a fast path into sick mode.
|
||||
delay = (
|
||||
base_delay
|
||||
if self.demo_mode
|
||||
else base_delay + NO_PHONE_EXTRA_LOCKOUT_SECONDS
|
||||
)
|
||||
self._label(
|
||||
"Cannot Verify Workout",
|
||||
font_size=36,
|
||||
|
||||
@ -15,6 +15,7 @@ import sys
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.screen_locker import _sick_tracker
|
||||
from python_pkg.screen_locker._constants import (
|
||||
EARLY_BIRD_END_HOUR,
|
||||
EARLY_BIRD_END_MINUTE,
|
||||
@ -34,6 +35,7 @@ from python_pkg.screen_locker._log_integrity import (
|
||||
)
|
||||
from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin
|
||||
from python_pkg.screen_locker._shutdown import ShutdownMixin
|
||||
from python_pkg.screen_locker._sick_dialog import SickDialogMixin
|
||||
from python_pkg.screen_locker._ui_flows import UIFlowsMixin
|
||||
from python_pkg.wake_alarm._state import has_workout_skip_today
|
||||
|
||||
@ -76,6 +78,7 @@ def _assert_not_under_pytest() -> None:
|
||||
class ScreenLocker(
|
||||
ShutdownMixin,
|
||||
PhoneVerificationMixin,
|
||||
SickDialogMixin,
|
||||
UIFlowsMixin,
|
||||
):
|
||||
"""Screen locker that requires workout logging to unlock."""
|
||||
@ -378,10 +381,26 @@ class ScreenLocker(
|
||||
_logger.info("Shutdown time moved 1.5 hours later as workout reward")
|
||||
return adjusted
|
||||
|
||||
def _clear_debt_on_verified_workout(self) -> int | None:
|
||||
"""Decrement workout debt by one for a verified workout.
|
||||
|
||||
Returns the new debt count, or ``None`` when this wasn't a
|
||||
phone-verified workout.
|
||||
"""
|
||||
if self.workout_data.get("type") != "phone_verified":
|
||||
return None
|
||||
history = _sick_tracker.load_history()
|
||||
if history.debt <= 0:
|
||||
return 0
|
||||
new_debt = _sick_tracker.clear_one_debt(history)
|
||||
_sick_tracker.save_history(history)
|
||||
return new_debt
|
||||
|
||||
def unlock_screen(self) -> None:
|
||||
"""Save workout log and display success message."""
|
||||
self.save_workout_log()
|
||||
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
|
||||
new_debt = self._clear_debt_on_verified_workout()
|
||||
self.clear_container()
|
||||
self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30)
|
||||
if shutdown_adjusted:
|
||||
@ -390,8 +409,20 @@ class ScreenLocker(
|
||||
font_size=24,
|
||||
color="#ffaa00",
|
||||
)
|
||||
if new_debt is not None:
|
||||
self._text(
|
||||
f"Workout debt: {new_debt}",
|
||||
font_size=20,
|
||||
color="#ffaa00" if new_debt > 0 else "#888888",
|
||||
)
|
||||
self._text("Screen Unlocked!", font_size=36, pady=20)
|
||||
self.root.after(1500, self.close)
|
||||
if self.workout_data.get("type") == "phone_verified":
|
||||
self.root.after(
|
||||
1500,
|
||||
lambda: self._show_commitment_prompt(on_done=self.close),
|
||||
)
|
||||
else:
|
||||
self.root.after(1500, self.close)
|
||||
|
||||
def has_logged_today(self) -> bool:
|
||||
"""Check if workout has been logged today with valid HMAC."""
|
||||
|
||||
@ -52,11 +52,29 @@ def _block_real_tk_and_exit() -> Iterator[None]:
|
||||
|
||||
with (
|
||||
patch("python_pkg.screen_locker.screen_lock.tk", mock),
|
||||
patch("python_pkg.screen_locker._sick_dialog.tk", mock),
|
||||
patch("python_pkg.screen_locker.screen_lock.sys.exit"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
|
||||
"""Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state."""
|
||||
target = tmp_path / "sick_history.json"
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.screen_locker._sick_tracker.SICK_HISTORY_FILE",
|
||||
target,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.screen_locker._constants.SICK_HISTORY_FILE",
|
||||
target,
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tk() -> Generator[MagicMock]:
|
||||
"""Mock tkinter module for testing without display."""
|
||||
|
||||
@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.screen_locker._constants import NO_PHONE_EXTRA_LOCKOUT_SECONDS
|
||||
from python_pkg.screen_locker.screen_lock import (
|
||||
PHONE_PENALTY_DELAY_DEMO,
|
||||
PHONE_PENALTY_DELAY_PRODUCTION,
|
||||
@ -516,13 +517,14 @@ class TestShowPhonePenalty:
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test production mode uses long penalty delay."""
|
||||
"""Test production mode uses long penalty delay (base + no-phone bump)."""
|
||||
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
||||
object.__setattr__(locker, "clear_container", MagicMock())
|
||||
|
||||
locker._show_phone_penalty("test message")
|
||||
|
||||
assert locker.phone_penalty_remaining == PHONE_PENALTY_DELAY_PRODUCTION - 1
|
||||
expected = PHONE_PENALTY_DELAY_PRODUCTION + NO_PHONE_EXTRA_LOCKOUT_SECONDS - 1
|
||||
assert locker.phone_penalty_remaining == expected
|
||||
|
||||
def test_update_phone_penalty_countdown(
|
||||
self,
|
||||
|
||||
449
screen_locker/tests/test_sick_features.py
Normal file
449
screen_locker/tests/test_sick_features.py
Normal file
@ -0,0 +1,449 @@
|
||||
"""Tests for sick-budget UI integration, finalize, debt-clear, and dialogs."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.screen_locker import _sick_tracker
|
||||
from python_pkg.screen_locker._sick_tracker import SickHistory
|
||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ui_flows.py — branches added for sick budget + finalize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShowRetryAndSickBudget:
|
||||
"""Tests for budget-aware _show_retry_and_sick."""
|
||||
|
||||
def test_shows_sick_button_when_budget_available(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
with patch.object(_sick_tracker, "load_history", return_value=SickHistory()):
|
||||
locker._show_retry_and_sick("nope")
|
||||
button_texts = {
|
||||
call.args[1] for call in mock_tk.Button.call_args_list if len(call.args) > 1
|
||||
}
|
||||
# Buttons are created via the helper which sets text via kwarg "text".
|
||||
button_texts |= {
|
||||
call.kwargs.get("text") for call in mock_tk.Button.call_args_list
|
||||
}
|
||||
assert "I'm sick" in button_texts
|
||||
|
||||
def test_hides_sick_button_when_budget_exhausted(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
full = SickHistory(sick_days=["2026-05-09"] * 99)
|
||||
with (
|
||||
patch.object(_sick_tracker, "load_history", return_value=full),
|
||||
patch.object(_sick_tracker, "is_budget_exhausted", return_value=True),
|
||||
):
|
||||
locker._show_retry_and_sick("nope")
|
||||
button_texts: set[str] = set()
|
||||
for call in mock_tk.Button.call_args_list:
|
||||
button_texts.add(call.kwargs.get("text", ""))
|
||||
assert "I'm sick" not in button_texts
|
||||
|
||||
|
||||
class TestProceedToSickCountdownLoadsHistory:
|
||||
"""Covers the no-cache branch of _proceed_to_sick_countdown."""
|
||||
|
||||
def test_loads_history_when_cache_missing(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(locker, "clear_container", MagicMock())
|
||||
object.__setattr__(
|
||||
locker, "_sick_mode_used_today", MagicMock(return_value=False)
|
||||
)
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_adjust_shutdown_time_earlier",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
with patch.object(
|
||||
_sick_tracker, "load_history", return_value=SickHistory()
|
||||
) as mock_load:
|
||||
locker._proceed_to_sick_countdown()
|
||||
mock_load.assert_called_once()
|
||||
assert hasattr(locker, "_sick_history_cache")
|
||||
|
||||
|
||||
class TestFinalizeSickDay:
|
||||
"""Covers _finalize_sick_day branches including commitment penalty."""
|
||||
|
||||
def test_marks_commitment_broken_and_writes_debt(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {}
|
||||
history = SickHistory(commitments={"2026-05-10": True})
|
||||
locker._sick_history_cache = history
|
||||
object.__setattr__(locker, "unlock_screen", MagicMock())
|
||||
with (
|
||||
patch.object(_sick_tracker, "had_commitment_for_today", return_value=True),
|
||||
patch.object(_sick_tracker, "save_history", return_value=True),
|
||||
):
|
||||
locker._finalize_sick_day()
|
||||
assert locker.workout_data["broke_commitment"] == "true"
|
||||
assert locker.workout_data["type"] == "sick_day"
|
||||
assert "debt" in locker.workout_data
|
||||
locker.unlock_screen.assert_called_once()
|
||||
|
||||
def test_loads_history_when_cache_missing(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {}
|
||||
object.__setattr__(locker, "unlock_screen", MagicMock())
|
||||
with (
|
||||
patch.object(
|
||||
_sick_tracker, "load_history", return_value=SickHistory()
|
||||
) as mock_load,
|
||||
patch.object(_sick_tracker, "save_history", return_value=True),
|
||||
):
|
||||
locker._finalize_sick_day()
|
||||
mock_load.assert_called_once()
|
||||
locker.unlock_screen.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# screen_lock.py — _clear_debt_on_verified_workout branches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClearDebtOnVerifiedWorkout:
|
||||
"""Tests for _clear_debt_on_verified_workout."""
|
||||
|
||||
def test_returns_none_when_not_phone_verified(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "sick_day"}
|
||||
assert locker._clear_debt_on_verified_workout() is None
|
||||
|
||||
def test_returns_zero_when_no_debt(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "phone_verified"}
|
||||
with patch.object(
|
||||
_sick_tracker, "load_history", return_value=SickHistory(debt=0)
|
||||
):
|
||||
assert locker._clear_debt_on_verified_workout() == 0
|
||||
|
||||
def test_decrements_when_debt_positive(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "phone_verified"}
|
||||
history = SickHistory(debt=2)
|
||||
with (
|
||||
patch.object(_sick_tracker, "load_history", return_value=history),
|
||||
patch.object(_sick_tracker, "save_history", return_value=True) as mock_save,
|
||||
):
|
||||
assert locker._clear_debt_on_verified_workout() == 1
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
class TestUnlockScreenCommitmentPrompt:
|
||||
"""Tests for unlock_screen branches around commitment prompt + debt label."""
|
||||
|
||||
def test_phone_verified_schedules_commitment_prompt(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "phone_verified"}
|
||||
locker.log_file = tmp_path / "log.json"
|
||||
object.__setattr__(locker, "save_workout_log", MagicMock())
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_try_adjust_shutdown_for_workout",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_clear_debt_on_verified_workout",
|
||||
MagicMock(return_value=0),
|
||||
)
|
||||
locker.unlock_screen()
|
||||
# The last after() call schedules the commitment prompt closure.
|
||||
last_call = locker.root.after.call_args_list[-1]
|
||||
assert last_call.args[0] == 1500
|
||||
|
||||
def test_non_verified_schedules_close_directly(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "sick_day"}
|
||||
locker.log_file = tmp_path / "log.json"
|
||||
object.__setattr__(locker, "save_workout_log", MagicMock())
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_try_adjust_shutdown_for_workout",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_clear_debt_on_verified_workout",
|
||||
MagicMock(return_value=None),
|
||||
)
|
||||
locker.unlock_screen()
|
||||
# close() goes through root.after directly.
|
||||
locker.root.after.assert_called_with(1500, locker.close)
|
||||
|
||||
def test_renders_debt_label_when_positive(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "phone_verified"}
|
||||
locker.log_file = tmp_path / "log.json"
|
||||
object.__setattr__(locker, "save_workout_log", MagicMock())
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_try_adjust_shutdown_for_workout",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_clear_debt_on_verified_workout",
|
||||
MagicMock(return_value=2),
|
||||
)
|
||||
locker.unlock_screen()
|
||||
# _text was called via mock_tk.Label; just assert a Label call mentions debt.
|
||||
labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list]
|
||||
assert any("Workout debt: 2" in t for t in labels)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sick_dialog.py — UI mixin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShowSickJustification:
|
||||
"""Tests for the structured sick justification dialog."""
|
||||
|
||||
def test_renders_form_without_commitment(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
with patch.object(_sick_tracker, "load_history", return_value=SickHistory()):
|
||||
locker._show_sick_justification()
|
||||
assert locker._sick_history_cache.sick_days == []
|
||||
assert hasattr(locker, "_sick_submit_button")
|
||||
# Submit button starts enabled (no commitment).
|
||||
# config(state="disabled") only called for commitment path.
|
||||
for call in locker._sick_submit_button.config.call_args_list:
|
||||
assert call.kwargs.get("state") != "disabled"
|
||||
|
||||
def test_renders_form_with_commitment_disables_submit(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
history = SickHistory(commitments={"2026-05-10": True})
|
||||
with (
|
||||
patch.object(_sick_tracker, "load_history", return_value=history),
|
||||
patch.object(_sick_tracker, "had_commitment_for_today", return_value=True),
|
||||
):
|
||||
locker._show_sick_justification()
|
||||
# Submit button was disabled and forced-delay started.
|
||||
states = [
|
||||
call.kwargs.get("state")
|
||||
for call in locker._sick_submit_button.config.call_args_list
|
||||
]
|
||||
assert "disabled" in states
|
||||
|
||||
def test_renders_recent_history_when_present(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
history = SickHistory(
|
||||
justifications=[
|
||||
{"date": "2026-05-01", "symptom": "fever", "severity": 7},
|
||||
],
|
||||
)
|
||||
with patch.object(_sick_tracker, "load_history", return_value=history):
|
||||
locker._show_sick_justification()
|
||||
labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list]
|
||||
assert any("Recent sick days" in t for t in labels)
|
||||
|
||||
|
||||
class TestUpdateCommitmentForcedDelay:
|
||||
"""Tests for _update_commitment_forced_delay."""
|
||||
|
||||
def test_ticks_down(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._sick_submit_button = MagicMock()
|
||||
locker._commitment_forced_remaining = 3
|
||||
locker._update_commitment_forced_delay()
|
||||
assert locker._commitment_forced_remaining == 2
|
||||
locker.root.after.assert_called()
|
||||
|
||||
def test_enables_when_done(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._sick_submit_button = MagicMock()
|
||||
locker._commitment_forced_remaining = 0
|
||||
locker._update_commitment_forced_delay()
|
||||
locker._sick_submit_button.config.assert_called_with(
|
||||
text="SUBMIT", state="normal"
|
||||
)
|
||||
|
||||
|
||||
class TestSubmitSickJustification:
|
||||
"""Tests for _submit_sick_justification validation + persistence."""
|
||||
|
||||
def _setup_locker(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
fields: dict[str, object] | None = None,
|
||||
) -> object:
|
||||
defaults: dict[str, object] = {
|
||||
"symptom": "fever",
|
||||
"onset": "last night",
|
||||
"severity": 7,
|
||||
"text": "x" * 200,
|
||||
}
|
||||
if fields:
|
||||
defaults.update(fields)
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._sick_history_cache = SickHistory()
|
||||
locker._sick_symptom_var = MagicMock()
|
||||
locker._sick_symptom_var.get.return_value = defaults["symptom"]
|
||||
locker._sick_onset_var = MagicMock()
|
||||
locker._sick_onset_var.get.return_value = defaults["onset"]
|
||||
locker._sick_severity_var = MagicMock()
|
||||
locker._sick_severity_var.get.return_value = defaults["severity"]
|
||||
locker._sick_text_widget = MagicMock()
|
||||
locker._sick_text_widget.get.return_value = defaults["text"]
|
||||
locker._sick_error_label = MagicMock()
|
||||
object.__setattr__(locker, "_proceed_to_sick_countdown", MagicMock())
|
||||
return locker
|
||||
|
||||
def test_validation_failure_displays_error(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = self._setup_locker(mock_tk, tmp_path, fields={"symptom": ""})
|
||||
locker._submit_sick_justification()
|
||||
locker._sick_error_label.config.assert_called_once()
|
||||
locker._proceed_to_sick_countdown.assert_not_called()
|
||||
|
||||
def test_severity_tcl_error_treated_as_invalid(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = self._setup_locker(mock_tk, tmp_path)
|
||||
locker._sick_severity_var.get.side_effect = ValueError("bad")
|
||||
locker._submit_sick_justification()
|
||||
locker._sick_error_label.config.assert_called_once()
|
||||
|
||||
def test_save_failure_displays_error(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = self._setup_locker(mock_tk, tmp_path)
|
||||
with patch.object(_sick_tracker, "save_history", return_value=False):
|
||||
locker._submit_sick_justification()
|
||||
locker._sick_error_label.config.assert_called_once()
|
||||
locker._proceed_to_sick_countdown.assert_not_called()
|
||||
|
||||
def test_success_proceeds_to_countdown(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = self._setup_locker(mock_tk, tmp_path)
|
||||
with patch.object(_sick_tracker, "save_history", return_value=True):
|
||||
locker._submit_sick_justification()
|
||||
locker._proceed_to_sick_countdown.assert_called_once()
|
||||
|
||||
|
||||
class TestCommitmentPrompt:
|
||||
"""Tests for _show_commitment_prompt + _tick_commitment_timeout + answer."""
|
||||
|
||||
def test_show_prompt_renders_buttons(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
on_done = MagicMock()
|
||||
locker._show_commitment_prompt(on_done=on_done)
|
||||
assert locker._commitment_done_fn is on_done
|
||||
assert locker._commitment_remaining > 0
|
||||
|
||||
def test_tick_decrements(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._commitment_remaining = 2
|
||||
locker._commitment_timer_label = MagicMock()
|
||||
locker._tick_commitment_timeout()
|
||||
assert locker._commitment_remaining == 1
|
||||
locker.root.after.assert_called()
|
||||
|
||||
def test_tick_zero_auto_answers_no(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
on_done = MagicMock()
|
||||
locker._commitment_done_fn = on_done
|
||||
locker._commitment_remaining = 0
|
||||
locker._commitment_timer_label = MagicMock()
|
||||
locker._tick_commitment_timeout()
|
||||
on_done.assert_called_once()
|
||||
|
||||
def test_answer_yes_persists_commitment(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
on_done = MagicMock()
|
||||
locker._commitment_done_fn = on_done
|
||||
history = SickHistory()
|
||||
with (
|
||||
patch.object(_sick_tracker, "load_history", return_value=history),
|
||||
patch.object(_sick_tracker, "save_history", return_value=True) as mock_save,
|
||||
):
|
||||
locker._answer_commitment(commit=True)
|
||||
mock_save.assert_called_once()
|
||||
on_done.assert_called_once()
|
||||
assert locker._commitment_done_fn is None
|
||||
|
||||
def test_answer_no_skips_persistence(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
on_done = MagicMock()
|
||||
locker._commitment_done_fn = on_done
|
||||
with patch.object(_sick_tracker, "save_history") as mock_save:
|
||||
locker._answer_commitment(commit=False)
|
||||
mock_save.assert_not_called()
|
||||
on_done.assert_called_once()
|
||||
|
||||
def test_answer_with_no_done_fn_is_safe(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
# No _commitment_done_fn attribute set.
|
||||
locker._answer_commitment(commit=False)
|
||||
|
||||
|
||||
class TestDisablePaste:
|
||||
"""Tests for the _disable_paste helper."""
|
||||
|
||||
def test_swallows_tcl_error(self) -> None:
|
||||
from python_pkg.screen_locker._sick_dialog import _disable_paste
|
||||
|
||||
widget = MagicMock()
|
||||
import tkinter as tk
|
||||
|
||||
widget.bind.side_effect = tk.TclError("nope")
|
||||
# Should not raise.
|
||||
_disable_paste(widget)
|
||||
386
screen_locker/tests/test_sick_tracker.py
Normal file
386
screen_locker/tests/test_sick_tracker.py
Normal file
@ -0,0 +1,386 @@
|
||||
"""Tests for the sick-day tracker pure-logic module."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.screen_locker import _sick_tracker
|
||||
from python_pkg.screen_locker._constants import (
|
||||
SICK_BUDGET_PER_7_DAYS,
|
||||
SICK_BUDGET_PER_30_DAYS,
|
||||
SICK_BUDGET_PER_90_DAYS,
|
||||
SICK_COMMITMENT_PENALTY_DAYS,
|
||||
SICK_HISTORY_REVIEW_COUNT,
|
||||
SICK_JUSTIFICATION_MIN_CHARS,
|
||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
|
||||
SICK_LOCKOUT_SECONDS,
|
||||
)
|
||||
from python_pkg.screen_locker._sick_tracker import (
|
||||
JustificationDraft,
|
||||
SickHistory,
|
||||
add_justification,
|
||||
add_sick_day,
|
||||
budget_summary,
|
||||
clear_one_debt,
|
||||
compute_lockout_seconds,
|
||||
count_in_window,
|
||||
format_recent_justifications,
|
||||
had_commitment_for_today,
|
||||
is_budget_exhausted,
|
||||
load_history,
|
||||
mark_commitment_broken,
|
||||
recent_justifications,
|
||||
record_commitment_for_tomorrow,
|
||||
save_history,
|
||||
validate_justification,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_TODAY = "2026-05-10"
|
||||
|
||||
|
||||
class TestLoadHistory:
|
||||
"""Tests for load_history."""
|
||||
|
||||
def test_returns_empty_when_file_missing(self) -> None:
|
||||
history = load_history()
|
||||
assert history == SickHistory()
|
||||
|
||||
def test_reads_existing_file(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "sick_history.json"
|
||||
target.write_text(
|
||||
'{"sick_days": ["2026-05-01"], "debt": 2,'
|
||||
' "commitments": {"2026-05-10": true},'
|
||||
' "broken_commitments": ["2026-05-09"],'
|
||||
' "justifications": [{"date": "2026-05-01"}]}'
|
||||
)
|
||||
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
|
||||
history = load_history()
|
||||
assert history.sick_days == ["2026-05-01"]
|
||||
assert history.debt == 2
|
||||
assert history.commitments == {"2026-05-10": True}
|
||||
assert history.broken_commitments == ["2026-05-09"]
|
||||
assert history.justifications == [{"date": "2026-05-01"}]
|
||||
|
||||
def test_returns_empty_on_corrupt_json(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "sick_history.json"
|
||||
target.write_text("not json")
|
||||
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
|
||||
assert load_history() == SickHistory()
|
||||
|
||||
def test_returns_empty_on_oserror(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "sick_history.json"
|
||||
target.write_text("{}")
|
||||
with (
|
||||
patch.object(_sick_tracker, "SICK_HISTORY_FILE", target),
|
||||
patch.object(type(target), "open", side_effect=OSError("boom")),
|
||||
):
|
||||
assert load_history() == SickHistory()
|
||||
|
||||
|
||||
class TestSaveHistory:
|
||||
"""Tests for save_history."""
|
||||
|
||||
def test_persists_history(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "sick_history.json"
|
||||
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
|
||||
history = SickHistory(sick_days=["2026-05-01"], debt=1)
|
||||
assert save_history(history) is True
|
||||
reloaded = load_history()
|
||||
assert reloaded == history
|
||||
|
||||
def test_returns_false_on_oserror(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "missing_dir" / "sick_history.json"
|
||||
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
|
||||
assert save_history(SickHistory()) is False
|
||||
|
||||
|
||||
class TestCountInWindow:
|
||||
"""Tests for count_in_window."""
|
||||
|
||||
def test_counts_only_within_window(self) -> None:
|
||||
history = SickHistory(
|
||||
sick_days=[
|
||||
"2026-05-09", # 1 day ago: in 7d, 30d, 90d
|
||||
"2026-05-03", # 7 days ago: NOT in 7d (cutoff exclusive)
|
||||
"2026-04-25", # 15 days ago: NOT in 7d, in 30d, 90d
|
||||
"2026-01-01", # ~130 days ago: outside 90d
|
||||
],
|
||||
)
|
||||
assert count_in_window(history, 7, today=_TODAY) == 1
|
||||
assert count_in_window(history, 30, today=_TODAY) == 3
|
||||
assert count_in_window(history, 90, today=_TODAY) == 3
|
||||
|
||||
def test_skips_invalid_date_strings(self) -> None:
|
||||
history = SickHistory(sick_days=["bad-date", "2026-05-09"])
|
||||
assert count_in_window(history, 7, today=_TODAY) == 1
|
||||
|
||||
def test_returns_zero_when_today_invalid(self) -> None:
|
||||
history = SickHistory(sick_days=["2026-05-09"])
|
||||
assert count_in_window(history, 7, today="bogus") == 0
|
||||
|
||||
def test_uses_today_default_when_none(self) -> None:
|
||||
history = SickHistory(sick_days=[])
|
||||
assert count_in_window(history, 7) == 0
|
||||
|
||||
|
||||
class TestIsBudgetExhausted:
|
||||
"""Tests for is_budget_exhausted."""
|
||||
|
||||
def test_false_when_under_budget(self) -> None:
|
||||
assert is_budget_exhausted(SickHistory(), today=_TODAY) is False
|
||||
|
||||
def test_true_when_weekly_exhausted(self) -> None:
|
||||
history = SickHistory(
|
||||
sick_days=["2026-05-09"] * SICK_BUDGET_PER_7_DAYS,
|
||||
)
|
||||
assert is_budget_exhausted(history, today=_TODAY) is True
|
||||
|
||||
def test_true_when_monthly_exhausted(self) -> None:
|
||||
# Spread far enough apart to all be in 30d but not 7d.
|
||||
history = SickHistory(
|
||||
sick_days=[
|
||||
"2026-05-08",
|
||||
"2026-04-28",
|
||||
"2026-04-18",
|
||||
][:SICK_BUDGET_PER_30_DAYS],
|
||||
)
|
||||
assert is_budget_exhausted(history, today=_TODAY) is True
|
||||
|
||||
def test_true_when_quarterly_exhausted(self) -> None:
|
||||
# All in 90d but only 1 in 30d.
|
||||
days = [
|
||||
"2026-05-09",
|
||||
"2026-04-01",
|
||||
"2026-03-15",
|
||||
"2026-03-10",
|
||||
"2026-03-05",
|
||||
"2026-03-01",
|
||||
"2026-02-28",
|
||||
"2026-02-25",
|
||||
"2026-02-20",
|
||||
"2026-02-15",
|
||||
]
|
||||
history = SickHistory(sick_days=days[:SICK_BUDGET_PER_90_DAYS])
|
||||
assert is_budget_exhausted(history, today=_TODAY) is True
|
||||
|
||||
|
||||
class TestComputeLockoutSeconds:
|
||||
"""Tests for compute_lockout_seconds."""
|
||||
|
||||
def test_base_when_no_recent(self) -> None:
|
||||
assert (
|
||||
compute_lockout_seconds(SickHistory(), today=_TODAY) == SICK_LOCKOUT_SECONDS
|
||||
)
|
||||
|
||||
def test_doubles_per_recent(self) -> None:
|
||||
history = SickHistory(sick_days=["2026-05-09", "2026-04-20"])
|
||||
recent = 2 # both within 30d
|
||||
expected = SICK_LOCKOUT_SECONDS * (SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent)
|
||||
assert compute_lockout_seconds(history, today=_TODAY) == expected
|
||||
|
||||
|
||||
class TestBudgetSummary:
|
||||
"""Tests for budget_summary."""
|
||||
|
||||
def test_renders_all_windows_and_debt(self) -> None:
|
||||
history = SickHistory(sick_days=["2026-05-09"], debt=3)
|
||||
summary = budget_summary(history, today=_TODAY)
|
||||
assert "Sick:" in summary
|
||||
assert "1/" in summary
|
||||
assert "Debt: 3" in summary
|
||||
|
||||
|
||||
class TestAddSickDay:
|
||||
"""Tests for add_sick_day."""
|
||||
|
||||
def test_adds_today_and_increments_debt(self) -> None:
|
||||
history = SickHistory()
|
||||
new_debt = add_sick_day(history, today=_TODAY)
|
||||
assert history.sick_days == [_TODAY]
|
||||
assert new_debt == 1
|
||||
|
||||
def test_idempotent_on_same_day(self) -> None:
|
||||
history = SickHistory(sick_days=[_TODAY], debt=0)
|
||||
new_debt = add_sick_day(history, today=_TODAY)
|
||||
assert history.sick_days == [_TODAY]
|
||||
# Debt still increments by 1 even if the date is already present.
|
||||
assert new_debt == 1
|
||||
|
||||
def test_double_penalty_when_commitment_broken(self) -> None:
|
||||
history = SickHistory(broken_commitments=[_TODAY])
|
||||
new_debt = add_sick_day(history, today=_TODAY)
|
||||
assert new_debt == SICK_COMMITMENT_PENALTY_DAYS
|
||||
|
||||
|
||||
class TestClearOneDebt:
|
||||
"""Tests for clear_one_debt."""
|
||||
|
||||
def test_decrements_when_positive(self) -> None:
|
||||
history = SickHistory(debt=2)
|
||||
assert clear_one_debt(history) == 1
|
||||
assert history.debt == 1
|
||||
|
||||
def test_clamped_at_zero(self) -> None:
|
||||
history = SickHistory(debt=0)
|
||||
assert clear_one_debt(history) == 0
|
||||
|
||||
|
||||
class TestRecordCommitment:
|
||||
"""Tests for record_commitment_for_tomorrow + had_commitment_for_today."""
|
||||
|
||||
def test_records_for_tomorrow(self) -> None:
|
||||
history = SickHistory()
|
||||
result = record_commitment_for_tomorrow(history, today=_TODAY)
|
||||
assert result == "2026-05-11"
|
||||
assert history.commitments["2026-05-11"] is True
|
||||
|
||||
def test_returns_today_when_today_invalid(self) -> None:
|
||||
history = SickHistory()
|
||||
result = record_commitment_for_tomorrow(history, today="bogus")
|
||||
assert result == "bogus"
|
||||
assert history.commitments == {}
|
||||
|
||||
def test_had_commitment_returns_true(self) -> None:
|
||||
history = SickHistory(commitments={_TODAY: True})
|
||||
assert had_commitment_for_today(history, today=_TODAY) is True
|
||||
|
||||
def test_had_commitment_returns_false(self) -> None:
|
||||
assert had_commitment_for_today(SickHistory(), today=_TODAY) is False
|
||||
|
||||
|
||||
class TestMarkCommitmentBroken:
|
||||
"""Tests for mark_commitment_broken."""
|
||||
|
||||
def test_appends_when_committed(self) -> None:
|
||||
history = SickHistory(commitments={_TODAY: True})
|
||||
mark_commitment_broken(history, today=_TODAY)
|
||||
assert history.broken_commitments == [_TODAY]
|
||||
|
||||
def test_idempotent(self) -> None:
|
||||
history = SickHistory(commitments={_TODAY: True}, broken_commitments=[_TODAY])
|
||||
mark_commitment_broken(history, today=_TODAY)
|
||||
assert history.broken_commitments == [_TODAY]
|
||||
|
||||
def test_noop_when_no_commitment(self) -> None:
|
||||
history = SickHistory()
|
||||
mark_commitment_broken(history, today=_TODAY)
|
||||
assert history.broken_commitments == []
|
||||
|
||||
|
||||
class TestValidateJustification:
|
||||
"""Tests for validate_justification."""
|
||||
|
||||
def _good_text(self) -> str:
|
||||
return "x" * SICK_JUSTIFICATION_MIN_CHARS
|
||||
|
||||
def _draft(
|
||||
self,
|
||||
*,
|
||||
symptom: str | None = None,
|
||||
onset: str | None = None,
|
||||
severity: int | None = None,
|
||||
text: str | None = None,
|
||||
) -> JustificationDraft:
|
||||
return JustificationDraft(
|
||||
symptom="fever" if symptom is None else symptom,
|
||||
onset="last night" if onset is None else onset,
|
||||
severity=7 if severity is None else severity,
|
||||
text=self._good_text() if text is None else text,
|
||||
)
|
||||
|
||||
def test_returns_none_when_valid(self) -> None:
|
||||
assert validate_justification(self._draft()) is None
|
||||
|
||||
def test_rejects_blank_symptom(self) -> None:
|
||||
assert validate_justification(self._draft(symptom=" ")) is not None
|
||||
|
||||
def test_rejects_blank_onset(self) -> None:
|
||||
assert validate_justification(self._draft(onset="")) is not None
|
||||
|
||||
@pytest.mark.parametrize("severity", [0, 11, -1])
|
||||
def test_rejects_severity_out_of_range(self, severity: int) -> None:
|
||||
assert validate_justification(self._draft(severity=severity)) is not None
|
||||
|
||||
def test_rejects_short_text(self) -> None:
|
||||
assert validate_justification(self._draft(text="too short")) is not None
|
||||
|
||||
|
||||
class TestAddJustification:
|
||||
"""Tests for add_justification."""
|
||||
|
||||
def _draft(self, text: str = " full description text ") -> JustificationDraft:
|
||||
return JustificationDraft(
|
||||
symptom="fever",
|
||||
onset="last night",
|
||||
severity=7,
|
||||
text=text,
|
||||
)
|
||||
|
||||
def test_appends_entry_with_hmac_when_key_present(self) -> None:
|
||||
history = SickHistory()
|
||||
with patch.object(_sick_tracker, "compute_entry_hmac", return_value="deadbeef"):
|
||||
entry = add_justification(history, self._draft(), today=_TODAY)
|
||||
assert history.justifications == [entry]
|
||||
assert entry["hmac"] == "deadbeef"
|
||||
assert entry["text"] == "full description text"
|
||||
assert entry["symptom"] == "fever"
|
||||
assert entry["severity"] == 7
|
||||
assert entry["date"] == _TODAY
|
||||
|
||||
def test_omits_hmac_when_key_unavailable(self) -> None:
|
||||
history = SickHistory()
|
||||
with patch.object(_sick_tracker, "compute_entry_hmac", return_value=None):
|
||||
entry = add_justification(
|
||||
history,
|
||||
self._draft(text="full description"),
|
||||
today=_TODAY,
|
||||
)
|
||||
assert "hmac" not in entry
|
||||
|
||||
|
||||
class TestRecentJustifications:
|
||||
"""Tests for recent_justifications + format_recent_justifications."""
|
||||
|
||||
def test_returns_last_n(self) -> None:
|
||||
history = SickHistory(
|
||||
justifications=[{"i": i} for i in range(5)],
|
||||
)
|
||||
assert recent_justifications(history, 2) == [{"i": 3}, {"i": 4}]
|
||||
|
||||
def test_returns_empty_list_when_n_zero(self) -> None:
|
||||
history = SickHistory(justifications=[{"i": 0}])
|
||||
assert recent_justifications(history, 0) == []
|
||||
|
||||
def test_default_n_is_review_count(self) -> None:
|
||||
history = SickHistory(
|
||||
justifications=[{"i": i} for i in range(SICK_HISTORY_REVIEW_COUNT + 5)],
|
||||
)
|
||||
assert len(recent_justifications(history)) == SICK_HISTORY_REVIEW_COUNT
|
||||
|
||||
def test_format_returns_empty_when_no_history(self) -> None:
|
||||
assert format_recent_justifications(SickHistory()) == ""
|
||||
|
||||
def test_format_renders_lines(self) -> None:
|
||||
history = SickHistory(
|
||||
justifications=[
|
||||
{"date": "2026-05-01", "symptom": "fever", "severity": 7},
|
||||
{"date": "2026-04-15", "symptom": "headache", "severity": 4},
|
||||
],
|
||||
)
|
||||
out = format_recent_justifications(history)
|
||||
assert "2026-05-01" in out
|
||||
assert "fever" in out
|
||||
assert "headache" in out
|
||||
|
||||
def test_format_handles_missing_fields(self) -> None:
|
||||
history = SickHistory(justifications=[{}])
|
||||
out = format_recent_justifications(history)
|
||||
assert "?" in out
|
||||
@ -121,27 +121,14 @@ class TestTimerLogic:
|
||||
class TestAskIfSick:
|
||||
"""Tests for ask_if_sick method."""
|
||||
|
||||
def test_ask_if_sick_displays_dialog(
|
||||
def test_ask_if_sick_invokes_justification_dialog(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test ask_if_sick shows sick day question."""
|
||||
"""ask_if_sick now delegates to the structured justification dialog."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(locker, "clear_container", MagicMock())
|
||||
object.__setattr__(locker, "_show_sick_justification", MagicMock())
|
||||
locker.ask_if_sick()
|
||||
locker.clear_container.assert_called_once()
|
||||
mock_tk.Label.assert_called()
|
||||
|
||||
|
||||
class TestSickQuestionButtons:
|
||||
"""Tests for _sick_question_buttons method."""
|
||||
|
||||
def test_creates_buttons(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test _sick_question_buttons creates yes/no buttons."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._sick_question_buttons()
|
||||
mock_tk.Button.assert_called()
|
||||
locker._show_sick_justification.assert_called_once_with()
|
||||
|
||||
|
||||
class TestGetSickDayStatus:
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"""Tests for handle_sick_day and sick day UI."""
|
||||
"""Tests for sick-day countdown flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from python_pkg.screen_locker._sick_tracker import SickHistory
|
||||
from python_pkg.screen_locker.screen_lock import (
|
||||
SICK_LOCKOUT_SECONDS,
|
||||
)
|
||||
@ -14,13 +15,13 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestHandleSickDay:
|
||||
"""Tests for handle_sick_day method."""
|
||||
class TestProceedToSickCountdown:
|
||||
"""Tests for _proceed_to_sick_countdown."""
|
||||
|
||||
def test_sets_up_countdown(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test handle_sick_day initializes sick day flow."""
|
||||
"""Countdown initialises with computed escalated value."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(locker, "clear_container", MagicMock())
|
||||
object.__setattr__(
|
||||
@ -29,8 +30,10 @@ class TestHandleSickDay:
|
||||
object.__setattr__(
|
||||
locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=True)
|
||||
)
|
||||
locker.handle_sick_day()
|
||||
locker._sick_history_cache = SickHistory()
|
||||
locker._proceed_to_sick_countdown()
|
||||
locker.clear_container.assert_called_once()
|
||||
# First tick has decremented once -> base - 1
|
||||
assert locker.sick_remaining_time == SICK_LOCKOUT_SECONDS - 1
|
||||
|
||||
|
||||
@ -40,8 +43,8 @@ class TestShowSickDayUi:
|
||||
def test_displays_ui(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test _show_sick_day_ui displays labels."""
|
||||
"""_show_sick_day_ui displays labels with explicit countdown."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._show_sick_day_ui("Test status", "#00aa00")
|
||||
locker._show_sick_day_ui("Test status", "#00aa00", 120)
|
||||
mock_tk.Label.assert_called()
|
||||
assert hasattr(locker, "sick_countdown_label")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user