mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
The early-bird window is the wake-alarm carrot, not a clock
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
05:00-08:30 (09:00 with the extended reward) locked nobody but did hold the workout lock off on a clock that knew nothing about whether the user was up, next to the morning-session rung that did. EarlyBirdMixin._is_early_bird_time is now that rung's verdict: open before the alarm, during the session, and until 11:00 once it completed in time -- exactly what wake-alarm signs. The wake_alarm_skip rung banks the pending marker so the carrot's end still tries a phone/RunnerUp workout before locking; early-bird-workout-check.timer moves to 09:30:30 and 11:00:30, the two possible ends. The consumer's boot-retry window starts at 05:00 with the refresher. Also: the sick screen lists four past justifications, one line each -- six full-length ones pushed it 7 px past 1366x768, which the screens-fit gate only sees with a real history on disk. Takes @types/node 26.6.2, eslint 10.11.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTdfXjbGYYrY7Quxcfz6mg
This commit is contained in:
parent
53bbdda419
commit
9ab7c09c79
11
AGENTS.md
11
AGENTS.md
@ -43,7 +43,16 @@ own `submitted_at`, flat +1h/day, and *fail closed* -- an unreadable ledger or
|
||||
key earns nothing. leetcode-guard itself stays read-only; it never writes
|
||||
the config.
|
||||
|
||||
## The morning session is the carrot
|
||||
## The morning session is the carrot — and the early-bird window is it
|
||||
|
||||
`EarlyBirdMixin._is_early_bird_time` no longer reads a clock: it is open
|
||||
exactly while wake-alarm's signed file grants an exemption (before the
|
||||
alarm, during the session, until 11:00 once it completed in time). The
|
||||
05:00–08:30 window and the extended-to-09:00 reward locked someone who got
|
||||
up at 07:00 and were a second mechanism next to the one that knows whether
|
||||
the user is up (2026-09-20). The pending marker is banked by the
|
||||
`wake_alarm_skip` rung; `early-bird-workout-check.timer` (09:30:30, 11:00:30)
|
||||
re-checks just after the carrot's two possible ends.
|
||||
|
||||
The `wake_alarm_skip` rung reads wake-alarm's HMAC-signed
|
||||
`~/.local/state/wake_alarm/morning_session.json` (`_morning_session.py`), not
|
||||
|
||||
@ -10,14 +10,17 @@ Description=Re-check workout after early bird grace period expires (08:30 normal
|
||||
# After=graphical-session.target, which is where that ordering belongs.
|
||||
|
||||
[Timer]
|
||||
# Fires at 08:30 for the normal early-bird window (5:00–8:30).
|
||||
# Also fires at 09:05 to catch the extended window (5:00–9:00) earned by 5+ workout weeks.
|
||||
# Both runs are idempotent: the service handles "already logged" and "window still active".
|
||||
OnCalendar=*-*-* 08:30:00
|
||||
OnCalendar=*-*-* 09:05:00
|
||||
# The early-bird window is the wake-alarm morning-session carrot since
|
||||
# 2026-09-20 (screen_locker/_early_bird.py): it ends at the signed
|
||||
# exempt_until -- 11:00 after a completed morning, min(start+90 min, 09:30)
|
||||
# after an aborted one, alarm+15 min when no session started. These runs land
|
||||
# just after the two boundaries the 5-minute tick could miss by up to five
|
||||
# minutes; every run is idempotent ("already logged", "carrot still in force").
|
||||
OnCalendar=*-*-* 09:30:30
|
||||
OnCalendar=*-*-* 11:00:30
|
||||
Unit=workout-locker.service
|
||||
# Persistent=true: a machine asleep or off at 08:30 would otherwise skip the
|
||||
# re-check entirely, leaving the early-bird marker banked and the day
|
||||
# Persistent=true: a machine asleep or off at a boundary would otherwise skip
|
||||
# the re-check entirely, leaving the early-bird marker banked and the day
|
||||
# unenforced. Both runs are idempotent (see above), so a catch-up run is safe.
|
||||
Persistent=true
|
||||
AccuracySec=1s
|
||||
|
||||
@ -21,7 +21,7 @@ import sys
|
||||
from screen_locker import _sick_tracker
|
||||
from screen_locker._decision_log import LockDecision, record_decision
|
||||
from screen_locker._decision_reasons import reasons_extra
|
||||
from screen_locker._morning_session import has_workout_skip_today, morning_skip_today
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@ -109,15 +109,13 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
# (_compliance_state, where the expired marker is non-terminal) said
|
||||
# "lock skipped". Fall through to the same ladder as any other morning.
|
||||
if pending and window_open:
|
||||
# The ONLY thing that closes this window is
|
||||
# early-bird-workout-check.timer. When that timer was disabled by an
|
||||
# ordering cycle (2026-08-04) this branch silently deferred the lock
|
||||
# every single day, forever.
|
||||
# The window is the wake-alarm carrot (see EarlyBirdMixin): it
|
||||
# closes when the signed exempt_until passes, and the 5-minute
|
||||
# tick plus early-bird-workout-check.timer then re-decide.
|
||||
_skip(
|
||||
"early_bird_window_active",
|
||||
"Early bird window still active — deferring to the 08:30/09:05 "
|
||||
"re-check timer.",
|
||||
recheck_by="early-bird-workout-check.timer",
|
||||
f"Morning-session carrot still in force: {self._morning_skip}.",
|
||||
recheck_by="workout-locker.timer",
|
||||
**self._other_conditions("early_bird_window_active"),
|
||||
)
|
||||
elif self._is_sick_day_today():
|
||||
@ -138,22 +136,18 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
"Workout already logged today.",
|
||||
**self._other_conditions("workout_logged_today"),
|
||||
)
|
||||
elif (skip := morning_skip_today(wait=True)) is not None:
|
||||
elif window_open:
|
||||
# Bank the marker so the run that sees the carrot end tries a
|
||||
# phone/RunnerUp workout before falling through to a lock.
|
||||
self._save_early_bird_pending()
|
||||
skip = self._morning_skip
|
||||
_skip(
|
||||
"wake_alarm_skip",
|
||||
f"Morning session earned it: {skip}.",
|
||||
exempt_until=skip.exempt_until.isoformat(),
|
||||
outcome=skip.outcome,
|
||||
f"Morning session earned it: {skip or 'carrot in force'}.",
|
||||
exempt_until=skip.exempt_until.isoformat() if skip else None,
|
||||
outcome=skip.outcome if skip else None,
|
||||
**self._other_conditions("wake_alarm_skip"),
|
||||
)
|
||||
elif window_open:
|
||||
self._save_early_bird_pending()
|
||||
_skip(
|
||||
"early_bird_banked",
|
||||
"Early bird time — banked the pending marker, will re-check "
|
||||
"at 08:30/09:05.",
|
||||
recheck_by="early-bird-workout-check.timer",
|
||||
)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
@ -21,11 +21,6 @@ from typing import TYPE_CHECKING
|
||||
import freedays
|
||||
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
||||
|
||||
from screen_locker._constants import (
|
||||
EARLY_BIRD_END_HOUR,
|
||||
EARLY_BIRD_END_MINUTE,
|
||||
EARLY_BIRD_START_HOUR,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._sick_tracker import is_sick_day as _is_sick_day
|
||||
@ -141,16 +136,3 @@ def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool
|
||||
the status layer have one predicate module to import from.
|
||||
"""
|
||||
return _is_sick_day(history, today=today)
|
||||
|
||||
|
||||
def _early_bird_window_open(*, extended: bool, local_minutes: int) -> bool:
|
||||
"""Deliberate, independent reimplementation of ``_is_early_bird_time``.
|
||||
|
||||
Not shared with ``EarlyBirdMixin._is_early_bird_time`` because too many
|
||||
existing tests are pinned to its ``_get_local_time_minutes`` patch point.
|
||||
Kept here as a small, separate, pure re-implementation for the status
|
||||
layer only — see the module docstring in ``_early_bird.py``.
|
||||
"""
|
||||
start = EARLY_BIRD_START_HOUR * 60
|
||||
end = 9 * 60 if extended else EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
|
||||
return start <= local_minutes < end
|
||||
|
||||
@ -22,7 +22,6 @@ from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._compliance_predicates import (
|
||||
_early_bird_window_open,
|
||||
has_logged_today,
|
||||
is_early_bird_pending,
|
||||
is_relaxed_day_skipped_today,
|
||||
@ -66,7 +65,6 @@ def explain_lock_decision(
|
||||
log_file: Path,
|
||||
early_bird_pending_file: Path,
|
||||
sick_history: SickHistory,
|
||||
extended_early_bird: bool,
|
||||
weekly_minimum_met: bool,
|
||||
relaxed_day: bool,
|
||||
wake_skip: bool = False,
|
||||
@ -82,16 +80,13 @@ def explain_lock_decision(
|
||||
instant = now if now is not None else datetime.now(tz=UTC)
|
||||
today_str = local_day(instant)
|
||||
local_dt = instant.astimezone()
|
||||
local_minutes = local_dt.hour * 60 + local_dt.minute
|
||||
|
||||
scheduled = is_scheduled_skip_today(local_dt.date())
|
||||
pending = is_early_bird_pending(early_bird_pending_file, today=today_str)
|
||||
window_open = _early_bird_window_open(
|
||||
extended=extended_early_bird, local_minutes=local_minutes
|
||||
)
|
||||
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
|
||||
window_open = wake_skip
|
||||
expired = pending and not window_open
|
||||
early_bird_open_and_pending = pending and window_open
|
||||
window_end_label = "09:00" if extended_early_bird else "08:30"
|
||||
sick_today = is_sick_day_today(sick_history, today=today_str)
|
||||
logged = has_logged_today(log_file, today=today_str)
|
||||
relaxed_day_already_skipped = relaxed_day and is_relaxed_day_skipped_today(
|
||||
@ -192,19 +187,6 @@ def explain_lock_decision(
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = _check(
|
||||
"early_bird_time_fresh",
|
||||
fired=window_open,
|
||||
reason_true=(
|
||||
f"Currently inside the early-bird window (05:00-{window_end_label})."
|
||||
),
|
||||
reason_false="Not currently inside the early-bird window.",
|
||||
terminal_reason="Inside the early-bird window — lock skipped, pending marker "
|
||||
f"would be saved for the {window_end_label} re-check.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = _check(
|
||||
"relaxed_day_already_skipped",
|
||||
fired=relaxed_day_already_skipped,
|
||||
|
||||
@ -31,6 +31,13 @@ SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2
|
||||
SICK_JUSTIFICATION_MIN_CHARS = 120
|
||||
# How many past sick justifications to show on the dialog (read-only).
|
||||
SICK_HISTORY_REVIEW_COUNT = 10
|
||||
# The sick screen lists past justifications one per line, at most this many
|
||||
# and each cut to this many characters: six full-length ones pushed the
|
||||
# screen 7 px past 1366x768, which the screens-fit gate only sees with a real
|
||||
# history on disk. The review *count* above is for the budget logic, not the
|
||||
# screen.
|
||||
SICK_SCREEN_RECENT_LINES = 4
|
||||
SICK_JUSTIFICATION_PREVIEW_CHARS = 36
|
||||
# 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.
|
||||
|
||||
@ -4,27 +4,22 @@ The early-bird "still waiting to see if a real workout shows up" flag is a
|
||||
same-day pending marker, not a workout — it is intentionally kept out of
|
||||
log.json (which is reserved for real outcomes) and instead lives in
|
||||
its own self-expiring, HMAC-signed state file, mirroring the pattern used by
|
||||
``wake_alarm._state`` in the companion wake-alarm service.
|
||||
``wake_alarm._state`` in the companion wake-alarm service. It is written when
|
||||
the wake-alarm carrot defers the lock, so that when the carrot ends the next
|
||||
run tries a phone/RunnerUp workout before locking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
|
||||
from screen_locker._compliance_state import is_early_bird_pending
|
||||
from screen_locker._constants import (
|
||||
EARLY_BIRD_END_HOUR,
|
||||
EARLY_BIRD_END_MINUTE,
|
||||
EARLY_BIRD_PENDING_FILE,
|
||||
EARLY_BIRD_START_HOUR,
|
||||
EXTRA_BENEFITS_FILE,
|
||||
)
|
||||
from screen_locker._constants import EARLY_BIRD_PENDING_FILE
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._extra_benefits import has_extended_early_bird
|
||||
from screen_locker._morning_session import MorningSkip, morning_skip_today
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -35,27 +30,27 @@ def _today_str() -> str:
|
||||
|
||||
|
||||
class EarlyBirdMixin:
|
||||
"""Mixin providing early-bird time window checks and pending-state helpers."""
|
||||
"""Mixin providing the early-bird window check and pending-state helpers.
|
||||
|
||||
def _get_local_time_minutes(self) -> int:
|
||||
"""Return current local time as minutes from midnight."""
|
||||
now = datetime.now(tz=UTC).astimezone()
|
||||
return now.hour * 60 + now.minute
|
||||
Since 2026-09-20 the window is not a wall clock but the wake-alarm
|
||||
morning-session carrot: open exactly while wake-alarm's signed
|
||||
``morning_session.json`` says the morning is being earned (before the
|
||||
alarm, during the session, and until 11:00 once it completed in time).
|
||||
The old 05:00-08:30 clock locked someone who got up at 07:00 -- the
|
||||
opposite of the goal -- and it was a second mechanism next to the one
|
||||
that actually knows whether the user is up.
|
||||
"""
|
||||
|
||||
_morning_skip: MorningSkip | None = None
|
||||
|
||||
def _is_early_bird_time(self) -> bool:
|
||||
"""Return True if current local time is in the early bird window.
|
||||
"""Whether the wake-alarm carrot is in force right now.
|
||||
|
||||
Normally the window closes at 08:30. When the current ISO week has an
|
||||
extended early-bird reward (earned by 5+ workouts the prior week) the
|
||||
window extends to 09:00.
|
||||
Waits (bounded) for a freshly booted PC's refresher; the verdict is
|
||||
kept on the instance so the skip detail can quote its ``exempt_until``.
|
||||
"""
|
||||
minutes = self._get_local_time_minutes()
|
||||
start = EARLY_BIRD_START_HOUR * 60
|
||||
if has_extended_early_bird(EXTRA_BENEFITS_FILE):
|
||||
end = 9 * 60 # 09:00
|
||||
else:
|
||||
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
|
||||
return start <= minutes < end
|
||||
self._morning_skip = morning_skip_today(wait=True)
|
||||
return self._morning_skip is not None
|
||||
|
||||
def _is_early_bird_pending(self) -> bool:
|
||||
"""Check if today has an unresolved early-bird pending marker."""
|
||||
|
||||
@ -32,8 +32,8 @@ if TYPE_CHECKING:
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Only inside this local window is a non-today file worth waiting for: the
|
||||
# refresher runs 07:00-11:00, so outside it "not today's" is simply the truth.
|
||||
MORNING_WINDOW: tuple[tuple[int, int], tuple[int, int]] = ((7, 0), (11, 0))
|
||||
# refresher runs 05:00-11:00, so outside it "not today's" is simply the truth.
|
||||
MORNING_WINDOW: tuple[tuple[int, int], tuple[int, int]] = ((5, 0), (11, 0))
|
||||
MORNING_RETRY_SECONDS: float = 30.0
|
||||
MORNING_POLL_SECONDS: float = 5.0
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ from screen_locker import _sick_tracker
|
||||
from screen_locker._constants import (
|
||||
SICK_COMMITMENT_FORCED_READ_SECONDS,
|
||||
SICK_JUSTIFICATION_MIN_CHARS,
|
||||
SICK_SCREEN_RECENT_LINES,
|
||||
)
|
||||
from screen_locker._sick_commitment import SickCommitmentMixin
|
||||
from screen_locker._surface_group import FrameGroup, TextGroup
|
||||
@ -40,7 +41,9 @@ class SickDialogMixin(SickCommitmentMixin):
|
||||
_sick_tracker.budget_summary(history), color=self._colors.palette.warning
|
||||
)
|
||||
|
||||
recent = _sick_tracker.format_recent_justifications(history)
|
||||
recent = _sick_tracker.format_recent_justifications(
|
||||
history, n=SICK_SCREEN_RECENT_LINES
|
||||
)
|
||||
if recent:
|
||||
self._text(
|
||||
"Recent sick days:",
|
||||
|
||||
@ -19,6 +19,7 @@ from screen_locker._constants import (
|
||||
SICK_HISTORY_FILE,
|
||||
SICK_HISTORY_REVIEW_COUNT,
|
||||
SICK_JUSTIFICATION_MIN_CHARS,
|
||||
SICK_JUSTIFICATION_PREVIEW_CHARS,
|
||||
)
|
||||
from screen_locker._sick_budget import (
|
||||
SickHistory,
|
||||
@ -235,7 +236,9 @@ def format_recent_justifications(
|
||||
lines: list[str] = []
|
||||
for entry in entries:
|
||||
date_str = entry.get("date", "?")
|
||||
symptom = entry.get("symptom", "?")
|
||||
symptom = str(entry.get("symptom", "?"))
|
||||
if len(symptom) > SICK_JUSTIFICATION_PREVIEW_CHARS:
|
||||
symptom = symptom[: SICK_JUSTIFICATION_PREVIEW_CHARS - 1] + "…"
|
||||
severity = entry.get("severity", "?")
|
||||
lines.append(f"{date_str} sev {severity}/10 — {symptom}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@ -194,7 +194,6 @@ def gather_status(
|
||||
log_file=log_file,
|
||||
early_bird_pending_file=early_bird_pending_file,
|
||||
sick_history=sick_history,
|
||||
extended_early_bird=early_bird_extended,
|
||||
weekly_minimum_met=week.counted_count >= WEEKLY_WORKOUT_MINIMUM,
|
||||
relaxed_day=is_relaxed_day(today=today_local),
|
||||
wake_skip=has_workout_skip_today(),
|
||||
|
||||
@ -28,7 +28,6 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"_constants.EXTRA_BENEFITS_FILE",
|
||||
"_startup_checks.EXTRA_BENEFITS_FILE",
|
||||
"_sync_mixin.EXTRA_BENEFITS_FILE",
|
||||
"_early_bird.EXTRA_BENEFITS_FILE",
|
||||
"_status.EXTRA_BENEFITS_FILE",
|
||||
),
|
||||
),
|
||||
|
||||
@ -38,7 +38,6 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
)
|
||||
@ -58,9 +57,9 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
wake_skip=True,
|
||||
now=now,
|
||||
)
|
||||
assert result.fired is False
|
||||
@ -80,7 +79,6 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
now=now,
|
||||
@ -98,7 +96,6 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(sick_days=[_today()]),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
|
||||
@ -115,7 +112,6 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
|
||||
@ -128,7 +124,6 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
wake_skip=True,
|
||||
@ -137,47 +132,25 @@ class TestExplainLockDecision:
|
||||
assert result.fired is False
|
||||
assert result.stage == "wake_alarm_skip"
|
||||
|
||||
def test_fresh_early_bird_time_skips(self, tmp_path: Path) -> None:
|
||||
def test_carrot_in_force_skips_as_wake_alarm(self, tmp_path: Path) -> None:
|
||||
"""Before the alarm, during the session, until 11:00 -- one stage, one reason."""
|
||||
files = self._files(tmp_path)
|
||||
now = datetime.now(tz=UTC).astimezone().replace(hour=6, minute=0)
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
now=now,
|
||||
wake_skip=True,
|
||||
)
|
||||
assert result.fired is False
|
||||
assert result.stage == "early_bird_time_fresh"
|
||||
assert "08:30" in result.reason
|
||||
assert "09:00" not in result.reason
|
||||
|
||||
def test_fresh_early_bird_time_names_09_00_when_extended(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""The re-check time isn't ambiguous — it's whichever one actually applies."""
|
||||
files = self._files(tmp_path)
|
||||
now = datetime.now(tz=UTC).astimezone().replace(hour=6, minute=0)
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=True,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
now=now,
|
||||
)
|
||||
assert result.fired is False
|
||||
assert result.stage == "early_bird_time_fresh"
|
||||
assert "09:00" in result.reason
|
||||
assert "08:30" not in result.reason
|
||||
assert result.stage == "wake_alarm_skip"
|
||||
assert "early-bird" not in result.reason
|
||||
|
||||
def test_relaxed_day_skips(self, tmp_path: Path) -> None:
|
||||
files = self._files(tmp_path)
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=True,
|
||||
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
|
||||
@ -201,7 +174,6 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=True,
|
||||
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
|
||||
@ -214,7 +186,6 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=True,
|
||||
relaxed_day=False,
|
||||
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
|
||||
@ -227,7 +198,6 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
|
||||
@ -243,7 +213,6 @@ class TestExplainLockDecision:
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
)
|
||||
|
||||
@ -2,52 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _compliance_state
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestEarlyBirdWindowOpen:
|
||||
"""The early-bird window's start/end boundaries, including the extension."""
|
||||
class TestEarlyBirdWindowIsTheCarrot:
|
||||
"""The window is whatever wake-alarm's signed file says -- no wall clock."""
|
||||
|
||||
"""Direct tests for the module-private, deliberately independent reimplementation."""
|
||||
def test_no_clock_predicate_remains(self) -> None:
|
||||
assert not hasattr(_compliance_state, "_early_bird_window_open")
|
||||
|
||||
def test_before_window(self) -> None:
|
||||
"""Before the start hour the window is shut."""
|
||||
assert (
|
||||
_compliance_state._early_bird_window_open(extended=False, local_minutes=299)
|
||||
is False
|
||||
def test_window_open_mirrors_wake_skip(self, tmp_path: Path) -> None:
|
||||
files = {
|
||||
"log_file": tmp_path / "log.json",
|
||||
"early_bird_pending_file": tmp_path / "eb.json",
|
||||
}
|
||||
open_ = _compliance_state.explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
wake_skip=True,
|
||||
)
|
||||
|
||||
def test_at_start(self) -> None:
|
||||
"""The start minute itself is inside the window."""
|
||||
assert (
|
||||
_compliance_state._early_bird_window_open(extended=False, local_minutes=300)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_before_end(self) -> None:
|
||||
"""A minute before the end is still inside the window."""
|
||||
assert (
|
||||
_compliance_state._early_bird_window_open(extended=False, local_minutes=509)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_at_end_exclusive(self) -> None:
|
||||
"""The end minute is exclusive: the window is already shut."""
|
||||
assert (
|
||||
_compliance_state._early_bird_window_open(extended=False, local_minutes=510)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_extended_before_end(self) -> None:
|
||||
"""With the extension earned, the window runs later."""
|
||||
assert (
|
||||
_compliance_state._early_bird_window_open(extended=True, local_minutes=539)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_extended_at_end_exclusive(self) -> None:
|
||||
"""The extended end is exclusive too."""
|
||||
assert (
|
||||
_compliance_state._early_bird_window_open(extended=True, local_minutes=540)
|
||||
is False
|
||||
shut = _compliance_state.explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
wake_skip=False,
|
||||
)
|
||||
assert open_.stage == "wake_alarm_skip"
|
||||
assert shut.stage == "would_lock"
|
||||
|
||||
@ -1,147 +1,47 @@
|
||||
"""Tests for early bird carrot feature in screen locker."""
|
||||
"""The early-bird window is the wake-alarm morning-session carrot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker.tests.conftest import (
|
||||
create_locker,
|
||||
)
|
||||
from screen_locker._morning_session import MorningSkip
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
|
||||
class TestGetLocalTimeMinutes:
|
||||
"""Tests for _get_local_time_minutes helper."""
|
||||
|
||||
def test_returns_int_within_day_range(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Returns an integer between 0 and 1439 (minutes in a day)."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
result = locker._get_local_time_minutes()
|
||||
assert isinstance(result, int)
|
||||
assert 0 <= result < 24 * 60
|
||||
_SKIP = MorningSkip(outcome="completed", exempt_until=datetime(2099, 1, 1).astimezone())
|
||||
|
||||
|
||||
class TestIsEarlyBirdTime:
|
||||
"""Tests for _is_early_bird_time based on local clock."""
|
||||
"""``_is_early_bird_time`` is the carrot verdict, kept for the skip detail."""
|
||||
|
||||
def _locker(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
tmp_path: Path,
|
||||
minutes: int,
|
||||
) -> ScreenLocker:
|
||||
def test_open_while_the_carrot_is_in_force(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_get_local_time_minutes",
|
||||
MagicMock(return_value=minutes),
|
||||
)
|
||||
return locker
|
||||
|
||||
def test_within_window(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""6:00 AM (360 min) is within the early bird window."""
|
||||
locker = self._locker(mock_tk, tmp_path, 360)
|
||||
assert locker._is_early_bird_time() is True
|
||||
|
||||
def test_at_start_of_window(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""5:00 AM (300 min) is the inclusive start of the window."""
|
||||
locker = self._locker(mock_tk, tmp_path, 300)
|
||||
assert locker._is_early_bird_time() is True
|
||||
|
||||
def test_just_before_start(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""4:59 AM (299 min) is before the window."""
|
||||
locker = self._locker(mock_tk, tmp_path, 299)
|
||||
assert locker._is_early_bird_time() is False
|
||||
|
||||
def test_just_before_end(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""8:29 AM (509 min) is still within the window."""
|
||||
locker = self._locker(mock_tk, tmp_path, 509)
|
||||
assert locker._is_early_bird_time() is True
|
||||
|
||||
def test_at_end_of_window(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""8:30 AM (510 min) is the exclusive end — not in window."""
|
||||
locker = self._locker(mock_tk, tmp_path, 510)
|
||||
assert locker._is_early_bird_time() is False
|
||||
|
||||
def test_after_window(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""9:00 AM (540 min) is past the window."""
|
||||
locker = self._locker(mock_tk, tmp_path, 540)
|
||||
assert locker._is_early_bird_time() is False
|
||||
|
||||
def test_extended_window_ends_at_9am(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When has_extended_early_bird is True, window closes at 09:00 (540 min)."""
|
||||
locker = self._locker(mock_tk, tmp_path, 539) # 08:59 — still inside
|
||||
with patch(
|
||||
"screen_locker._early_bird.has_extended_early_bird",
|
||||
return_value=True,
|
||||
):
|
||||
"screen_locker._early_bird.morning_skip_today", return_value=_SKIP
|
||||
) as read:
|
||||
assert locker._is_early_bird_time() is True
|
||||
read.assert_called_once_with(wait=True)
|
||||
assert locker._morning_skip == _SKIP
|
||||
|
||||
def test_extended_window_closed_at_9am(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
def test_shut_without_a_carrot(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Extended window excludes exactly 09:00 (540 min)."""
|
||||
locker = self._locker(mock_tk, tmp_path, 540) # 09:00 — exclusive end
|
||||
with patch(
|
||||
"screen_locker._early_bird.has_extended_early_bird",
|
||||
return_value=True,
|
||||
):
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
with patch("screen_locker._early_bird.morning_skip_today", return_value=None):
|
||||
assert locker._is_early_bird_time() is False
|
||||
assert locker._morning_skip is None
|
||||
|
||||
def test_midnight(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
def test_no_wall_clock_is_consulted(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Midnight (0 min) is outside the window."""
|
||||
locker = self._locker(mock_tk, tmp_path, 0)
|
||||
"""The 05:00-08:30 clock is gone: 07:00 with a carrot is open, 06:00 without is shut."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
assert not hasattr(locker, "_get_local_time_minutes")
|
||||
with patch("screen_locker._early_bird.morning_skip_today", return_value=None):
|
||||
assert locker._is_early_bird_time() is False
|
||||
|
||||
@ -125,7 +125,7 @@ class TestInitEarlyBirdFlow:
|
||||
patch.object(ScreenLocker, "_start_phone_check"),
|
||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
||||
patch(
|
||||
"screen_locker._auto_upgrade.morning_skip_today",
|
||||
"screen_locker._early_bird.morning_skip_today",
|
||||
return_value=None,
|
||||
),
|
||||
pytest.raises(SystemExit),
|
||||
|
||||
@ -101,7 +101,6 @@ class TestAfterMidnightLog:
|
||||
log_file=log_file,
|
||||
early_bird_pending_file=tmp_path / "early_bird_pending.json",
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=True,
|
||||
relaxed_day=False,
|
||||
now=JUST_AFTER_MIDNIGHT_UTC,
|
||||
|
||||
@ -11,6 +11,7 @@ from screen_locker import _sick_tracker
|
||||
from screen_locker._constants import (
|
||||
SICK_HISTORY_REVIEW_COUNT,
|
||||
SICK_JUSTIFICATION_MIN_CHARS,
|
||||
SICK_JUSTIFICATION_PREVIEW_CHARS,
|
||||
)
|
||||
from screen_locker._sick_tracker import (
|
||||
JustificationDraft,
|
||||
@ -198,3 +199,14 @@ class TestRecentJustifications:
|
||||
history = SickHistory(justifications=[{}])
|
||||
out = format_recent_justifications(history)
|
||||
assert "?" in out
|
||||
|
||||
def test_format_keeps_a_long_symptom_to_one_line(self) -> None:
|
||||
"""A 48-char symptom wrapped and pushed the sick screen past 768 px."""
|
||||
history = SickHistory(
|
||||
justifications=[
|
||||
{"date": "2026-05-01", "symptom": "x" * 48, "severity": 7},
|
||||
],
|
||||
)
|
||||
out = format_recent_justifications(history)
|
||||
assert out.endswith("…")
|
||||
assert len(out.split("— ")[1]) == SICK_JUSTIFICATION_PREVIEW_CHARS
|
||||
|
||||
@ -62,7 +62,6 @@ class TestUnreadableSourceIsNotNoWorkout:
|
||||
**self._files(tmp_path),
|
||||
now=self._NOON,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
degraded_sources=(_sync_client.DegradedSource("firebase", "HTTP 400"),),
|
||||
@ -77,7 +76,6 @@ class TestUnreadableSourceIsNotNoWorkout:
|
||||
**self._files(tmp_path),
|
||||
now=self._NOON,
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
)
|
||||
|
||||
@ -122,16 +122,16 @@ class TestEnforcementTimersAreCycleFree:
|
||||
|
||||
|
||||
class TestEarlyBirdTimerWindows:
|
||||
"""The early-bird timer must keep closing both grace windows."""
|
||||
"""The early-bird timer must re-check just after both carrot boundaries."""
|
||||
|
||||
def test_covers_normal_and_extended_windows(self) -> None:
|
||||
"""08:30 closes the normal window; 09:05 closes the extended one."""
|
||||
def test_covers_both_carrot_boundaries(self) -> None:
|
||||
"""09:30 ends an aborted morning's grace; 11:00 ends a completed one's."""
|
||||
text = (REPO_ROOT / "early-bird-workout-check.timer").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
calendars = _directive_values(text, "OnCalendar")
|
||||
assert "*-*-* 08:30:00" in calendars
|
||||
assert "*-*-* 09:05:00" in calendars
|
||||
assert "*-*-* 09:30:30" in calendars
|
||||
assert "*-*-* 11:00:30" in calendars
|
||||
|
||||
|
||||
class TestPeriodicLockerTimer:
|
||||
|
||||
@ -1,87 +1,70 @@
|
||||
"""Tests for wake alarm skip integration in screen_lock.py."""
|
||||
"""The wake-alarm carrot in the lock's startup ladder (``_check_today_state_exits``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker._morning_session import MorningSkip
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
_SKIP = MorningSkip(outcome="completed", exempt_until=datetime(2099, 1, 1).astimezone())
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
def _locker(
|
||||
tmp_path: Path, *, carrot: MorningSkip | None, has_logged: bool = False
|
||||
) -> None:
|
||||
"""Build a locker whose early-bird window is the given carrot verdict."""
|
||||
with (
|
||||
patch.object(Path, "resolve", return_value=tmp_path / "screen_locker"),
|
||||
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
|
||||
patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
|
||||
patch.object(ScreenLocker, "_is_early_bird_pending", return_value=False),
|
||||
patch("screen_locker._early_bird.morning_skip_today", return_value=carrot),
|
||||
patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False),
|
||||
patch.object(ScreenLocker, "_start_phone_check"),
|
||||
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
|
||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
||||
patch.object(ScreenLocker, "_scan_and_fill_week_runnerup", return_value=0),
|
||||
):
|
||||
ScreenLocker(demo_mode=True)
|
||||
|
||||
|
||||
class TestWakeSkipIntegration:
|
||||
"""Tests for workout skip via wake alarm in screen locker init."""
|
||||
|
||||
def test_exits_when_wake_skip_active(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
def test_exits_and_banks_the_marker_while_the_carrot_is_in_force(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Screen locker exits if wake alarm granted workout skip today."""
|
||||
with patch(
|
||||
"screen_locker._auto_upgrade.morning_skip_today",
|
||||
return_value=_SKIP,
|
||||
with (
|
||||
patch.object(ScreenLocker, "_save_early_bird_pending") as save,
|
||||
patch("screen_locker._auto_upgrade.record_decision") as record,
|
||||
):
|
||||
create_locker(mock_tk, tmp_path, has_logged=False)
|
||||
|
||||
_locker(tmp_path, carrot=_SKIP)
|
||||
mock_sys_exit.assert_called_once_with(0)
|
||||
save.assert_called_once()
|
||||
decision = record.call_args.args[0]
|
||||
assert decision.reason == "wake_alarm_skip"
|
||||
assert decision.extra["exempt_until"] == _SKIP.exempt_until.isoformat()
|
||||
assert "no lock until" in decision.detail
|
||||
|
||||
def test_does_not_exit_when_no_wake_skip(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
def test_does_not_exit_without_a_carrot(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Screen locker proceeds normally if no wake skip active."""
|
||||
with patch(
|
||||
"screen_locker._auto_upgrade.morning_skip_today",
|
||||
return_value=None,
|
||||
):
|
||||
locker = create_locker(mock_tk, tmp_path, has_logged=False)
|
||||
|
||||
_locker(tmp_path, carrot=None)
|
||||
mock_sys_exit.assert_not_called()
|
||||
assert locker is not None
|
||||
|
||||
def test_logged_today_takes_precedence(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""has_logged_today exits before wake skip is even checked."""
|
||||
with patch(
|
||||
"screen_locker._auto_upgrade.morning_skip_today",
|
||||
return_value=_SKIP,
|
||||
):
|
||||
create_locker(mock_tk, tmp_path, has_logged=True)
|
||||
|
||||
# Exits because has_logged_today, not because of wake skip
|
||||
with patch("screen_locker._auto_upgrade.record_decision") as record:
|
||||
_locker(tmp_path, carrot=_SKIP, has_logged=True)
|
||||
mock_sys_exit.assert_called_once_with(0)
|
||||
assert record.call_args.args[0].reason == "workout_logged_today"
|
||||
|
||||
def test_verify_only_mode_ignores_wake_skip(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""verify_only mode checks sick day log, not wake skip."""
|
||||
with patch(
|
||||
"screen_locker._auto_upgrade.morning_skip_today",
|
||||
return_value=_SKIP,
|
||||
):
|
||||
create_locker(
|
||||
mock_tk,
|
||||
tmp_path,
|
||||
verify_only=True,
|
||||
is_sick_day_log=True,
|
||||
)
|
||||
|
||||
# In verify_only mode, exits don't happen from wake skip path
|
||||
with patch("screen_locker._early_bird.morning_skip_today", return_value=_SKIP):
|
||||
create_locker(mock_tk, tmp_path, verify_only=True, is_sick_day_log=True)
|
||||
mock_sys_exit.assert_not_called()
|
||||
|
||||
@ -151,7 +151,7 @@ class TestCheckTodayStateExits:
|
||||
patch.object(locker, "_is_sick_day_today", return_value=False),
|
||||
patch.object(locker, "has_logged_today", return_value=False),
|
||||
patch(
|
||||
"screen_locker._auto_upgrade.morning_skip_today",
|
||||
"screen_locker._early_bird.morning_skip_today",
|
||||
return_value=_SKIP,
|
||||
),
|
||||
):
|
||||
@ -171,7 +171,7 @@ class TestCheckTodayStateExits:
|
||||
patch.object(locker, "_is_sick_day_today", return_value=False),
|
||||
patch.object(locker, "has_logged_today", return_value=False),
|
||||
patch(
|
||||
"screen_locker._auto_upgrade.morning_skip_today",
|
||||
"screen_locker._early_bird.morning_skip_today",
|
||||
return_value=None,
|
||||
),
|
||||
patch.object(locker, "_is_early_bird_time", return_value=True),
|
||||
@ -193,7 +193,7 @@ class TestCheckTodayStateExits:
|
||||
patch.object(locker, "_is_sick_day_today", return_value=False),
|
||||
patch.object(locker, "has_logged_today", return_value=False),
|
||||
patch(
|
||||
"screen_locker._auto_upgrade.morning_skip_today",
|
||||
"screen_locker._early_bird.morning_skip_today",
|
||||
return_value=None,
|
||||
),
|
||||
patch.object(locker, "_is_early_bird_time", return_value=False),
|
||||
|
||||
35
web/package-lock.json
generated
35
web/package-lock.json
generated
@ -17,12 +17,12 @@
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.6.7",
|
||||
"@types/node": "26.6.1",
|
||||
"@types/node": "26.6.2",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@vitest/coverage-v8": "5.0.1",
|
||||
"eslint": "10.10.0",
|
||||
"eslint": "10.11.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.7",
|
||||
"globals": "17.12.0",
|
||||
@ -124,7 +124,6 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@ -479,7 +478,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@ -528,7 +526,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@ -1097,7 +1094,6 @@
|
||||
"integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@ -1237,12 +1233,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz",
|
||||
"integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==",
|
||||
"version": "26.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
|
||||
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
@ -1253,7 +1248,6 @@
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@ -1264,7 +1258,6 @@
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
@ -1314,7 +1307,6 @@
|
||||
"integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.70.0",
|
||||
"@typescript-eslint/types": "8.70.0",
|
||||
@ -1549,7 +1541,6 @@
|
||||
"integrity": "sha512-FRC8ACiudC3dI6MTplzRSYWHDRnIv2IPfbzs4FdoJNsMal/35sWV8hwIfV8ZcqzSPy+uXHeMVONt9CEqtOU17w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/istanbul-lib-coverage": "^1.0.0",
|
||||
@ -1640,7 +1631,6 @@
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -1803,7 +1793,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
@ -2052,12 +2041,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.10.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz",
|
||||
"integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==",
|
||||
"version": "10.11.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
|
||||
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@ -2613,7 +2601,6 @@
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
@ -3233,7 +3220,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -3243,7 +3229,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.28.0"
|
||||
},
|
||||
@ -3537,7 +3522,6 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -3634,7 +3618,6 @@
|
||||
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
@ -3713,7 +3696,6 @@
|
||||
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.1",
|
||||
@ -3925,7 +3907,6 @@
|
||||
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@ -23,12 +23,12 @@
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.6.7",
|
||||
"@types/node": "26.6.1",
|
||||
"@types/node": "26.6.2",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@vitest/coverage-v8": "5.0.1",
|
||||
"eslint": "10.10.0",
|
||||
"eslint": "10.11.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.7",
|
||||
"globals": "17.12.0",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user