screen-locker/screen_locker/tests/test_status_view.py
Krzysztof kuhy Rudnicki 7d8daca111 sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0)
and nothing else. Removed: the 7/30/90-day budget, the escalating lockout
countdown, the justification form, the commitment prompt and its double
debt, the workout-debt counter, the -60 min shutdown cut and its
sick_day_state.json, the post-sick-day --verify-workout mode, the sick
auto-upgrade, scripts/revoke_sick_day.py and their tests.

- Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens;
  one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort
  background sickdays.sync_quietly -> unlock. Refusals (free day,
  unreadable pool) are shown and the lock stays.
- Shutdown: the reset resolves with earned_time.resolve(..., sick=True)
  (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace
  lift, so later credits absorb it instead of stacking on 20:00. A day
  marked after the reset is lifted by the new _sick_floor live pass (every
  5-min tick, --sync-only, --apply-bonuses, and the tap itself).
- Workout lock and grace floor stand down on a sick day; workouts that day
  credit normally through the sync paths.
- wake-alarm workday_penalty.json is void when its issued_date or
  penalty_date is a sick day.
- Status window, i3 line, CLI --status, MCP flags and web reasons read
  sickdays.
- scripts/migrate_sick_history.py imports sick_history.json's dates via
  sickdays.import_legacy, verifies and removes the file; idempotent, run by
  install.sh.
- Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending);
  pre-commit 4.7.0, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:30:39 +02:00

180 lines
6.7 KiB
Python

"""Tests for the read-only Tkinter status window's rendering (StatusWindow.render)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from screen_locker._compliance_state import AutoUpgradeOpportunity
if TYPE_CHECKING:
from unittest.mock import MagicMock
from screen_locker.tests._status_view_helpers import (
_day,
_lock_explanation,
_make_window,
_shutdown,
_snapshot,
_texts,
_week,
)
class TestSectionToday:
"""SectionToday."""
def test_counted_shows_checkmark_and_entry(self, mock_tk: MagicMock) -> None:
"""Counted shows checkmark and entry."""
snap = _snapshot(
today=_day(
entry_types=("phone_verified",),
counted=True,
day_count=1,
source="gym",
)
)
_make_window(mock_tk, snap)
texts = _texts(mock_tk)
assert any("✓" in t and "phone_verified" in t for t in texts)
assert any(t == "gym" for t in texts)
def test_sick_day_shows_sick_mark(self, mock_tk: MagicMock) -> None:
"""Sick day shows sick mark."""
snap = _snapshot(today=_day(entry_types=(), is_sick_day=True))
_make_window(mock_tk, snap)
assert any("😷" in t and "sick day" in t for t in _texts(mock_tk))
def test_no_entry_shows_dash(self, mock_tk: MagicMock) -> None:
"""No entry shows dash."""
snap = _snapshot(today=_day(entry_types=(), is_sick_day=False))
_make_window(mock_tk, snap)
assert any("no entry yet" in t for t in _texts(mock_tk))
def test_empty_source_adds_no_extra_line(self, mock_tk: MagicMock) -> None:
"""Empty source adds no extra line."""
snap = _snapshot(
today=_day(
entry_types=("phone_verified",),
counted=True,
day_count=1,
source="",
)
)
_make_window(mock_tk, snap)
# Only the main "Today (...)" line, no second call for an empty source.
today_related = [t for t in _texts(mock_tk) if "Today" in t]
assert len(today_related) == 1
class TestSectionWeek:
"""SectionWeek."""
def test_shows_remaining_message_when_under_minimum(
self, mock_tk: MagicMock
) -> None:
"""Shows remaining message when under minimum."""
snap = _snapshot(week=_week(counted_count=2, remaining=2, extra=0))
_make_window(mock_tk, snap)
assert any("Need 2 more this week." in t for t in _texts(mock_tk))
def test_shows_extra_message_when_over_minimum(self, mock_tk: MagicMock) -> None:
"""Shows extra message when over minimum."""
snap = _snapshot(week=_week(counted_count=5, remaining=0, extra=1))
_make_window(mock_tk, snap)
assert any("above the weekly minimum" in t for t in _texts(mock_tk))
def test_shows_neither_message_at_exact_minimum(self, mock_tk: MagicMock) -> None:
"""Shows neither message at exact minimum."""
snap = _snapshot(week=_week(counted_count=4, remaining=0, extra=0))
_make_window(mock_tk, snap)
texts = _texts(mock_tk)
assert not any("Need" in t and "more this week" in t for t in texts)
assert not any("above the weekly minimum" in t for t in texts)
def test_renders_a_line_per_day(self, mock_tk: MagicMock) -> None:
"""Renders a line per day."""
days = (
_day(
date="2024-01-01",
label="Mon Jan 01",
counted=True,
day_count=1,
entry_types=("phone_verified",),
),
_day(date="2024-01-02", label="Tue Jan 02", is_sick_day=True),
_day(date="2024-01-03", label="Wed Jan 03"),
)
snap = _snapshot(week=_week(days=days))
_make_window(mock_tk, snap)
texts = _texts(mock_tk)
assert any("Mon Jan 01" in t and "✓" in t for t in texts)
assert any("Tue Jan 02" in t and "😷" in t for t in texts)
assert any("Wed Jan 03" in t and "no entry" in t for t in texts)
class TestSectionLockExplanation:
"""SectionLockExplanation."""
def test_fired_with_auto_upgrade_and_heat_skip_pending(
self, mock_tk: MagicMock
) -> None:
"""Fired with auto upgrade and heat skip pending."""
snap = _snapshot(
lock_explanation=_lock_explanation(
fired=True,
reason="Full lock.",
auto_upgrade=AutoUpgradeOpportunity(
would_attempt=True, via="sick_day", reason="will try phone"
),
heat_skip_evaluated=False,
)
)
_make_window(mock_tk, snap)
texts = _texts(mock_tk)
assert any(t == "Full lock." for t in texts)
assert any("Pending auto-upgrade: will try phone" in t for t in texts)
def test_not_fired_no_auto_upgrade(self, mock_tk: MagicMock) -> None:
"""Not fired no auto upgrade."""
snap = _snapshot(
lock_explanation=_lock_explanation(fired=False, reason="Skipped.")
)
_make_window(mock_tk, snap)
texts = _texts(mock_tk)
assert any(t == "Skipped." for t in texts)
assert not any("Pending auto-upgrade" in t for t in texts)
def test_temperature_section_renders_regardless_of_heat_skip_evaluated(
self, mock_tk: MagicMock
) -> None:
"""The live temperature section is unconditional — see
``TestTemperatureSectionRendering`` for its actual content states."""
snap = _snapshot(
lock_explanation=_lock_explanation(fired=True, heat_skip_evaluated=True)
)
_make_window(mock_tk, snap)
assert any("Warsaw Temperature" in t for t in _texts(mock_tk))
class TestSectionShutdown:
"""SectionShutdown."""
def test_tonight_present_shows_live_config(self, mock_tk: MagicMock) -> None:
"""Tonight present shows live config."""
snap = _snapshot(shutdown=_shutdown(tonight=(1320, 1380, 300)))
_make_window(mock_tk, snap)
assert any("22:00" in t and "23:00" in t for t in _texts(mock_tk))
def test_tonight_absent_shows_unavailable_message(self, mock_tk: MagicMock) -> None:
"""Tonight absent shows unavailable message."""
snap = _snapshot(shutdown=_shutdown(tonight=None))
_make_window(mock_tk, snap)
assert any("Live shutdown config unavailable." in t for t in _texts(mock_tk))
def test_shows_rest_of_week_and_next_week_preview(self, mock_tk: MagicMock) -> None:
"""Shows rest of week and next week preview."""
snap = _snapshot()
_make_window(mock_tk, snap)
texts = _texts(mock_tk)
assert any("Rest of week:" in t for t in texts)
assert any("Next week (speculative):" in t for t in texts)