mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 17:23:05 +02:00
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.
387 lines
13 KiB
Python
387 lines
13 KiB
Python
"""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
|