mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
sick day: shutdown = 20:00 + gaming pay of today's credits (earned_time 0.13.1); credits raise to it, no absorb
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 (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (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 (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9e23ebae4f
commit
f2ddf3a491
@ -136,7 +136,7 @@ repos:
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.0#subdirectory=earned_time
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.1#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
- mcp>=2,<3
|
||||
@ -158,7 +158,7 @@ repos:
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.0#subdirectory=earned_time
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.1#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
- mcp>=2,<3
|
||||
|
||||
@ -10,7 +10,7 @@ dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.0#subdirectory=earned_time",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.1#subdirectory=earned_time",
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays",
|
||||
]
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.16.2
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.0#subdirectory=earned_time
|
||||
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.1#subdirectory=earned_time
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
mcp==2.3.0
|
||||
|
||||
@ -16,6 +16,7 @@ from screen_locker._constants import (
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, first_minutes, hhmm, span
|
||||
from screen_locker._grace_floor import GraceFloorMixin, plan_absorb, save_lift
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._sick_floor import SickFloorMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -76,6 +77,8 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
day = date.fromisoformat(today_str())
|
||||
if is_sick(day):
|
||||
return self._raise_on_sick_day()
|
||||
bonus, lift = plan_absorb(first_minutes(earned_time.WORKOUT, day))
|
||||
try:
|
||||
config_values = self._read_shutdown_config()
|
||||
@ -104,6 +107,8 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if is_sick(date.fromisoformat(today_str())):
|
||||
return self._raise_on_sick_day()
|
||||
# Paid out of the grace lift first, so the floor is never stacked on.
|
||||
moved, lift = plan_absorb(extra_minutes)
|
||||
try:
|
||||
@ -126,6 +131,17 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
|
||||
save_lift(lift)
|
||||
return ok
|
||||
|
||||
def _raise_on_sick_day(self) -> bool:
|
||||
"""A sick day's credit: raise to the derived target, never add.
|
||||
|
||||
The credit's row is already in the target (``_sick_floor``), so adding
|
||||
its minutes on top would pay it twice. True even when nothing moved
|
||||
(capped, or already there): the credit is accounted for, and a failed
|
||||
write is retried by the next tick's sick pass.
|
||||
"""
|
||||
self._apply_sick_floor()
|
||||
return True
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""
|
||||
return read_shutdown_config(SHUTDOWN_CONFIG_FILE)
|
||||
|
||||
@ -78,7 +78,7 @@ def base_minutes(day: date | None = None) -> int:
|
||||
else earned_time.base_for(target, earners, first_credits=starts)
|
||||
).shutdown_minutes
|
||||
if is_sick(target):
|
||||
return earned_time.sick_shutdown_minutes(base, target)
|
||||
return max(base, earned_time.sick_shutdown_from(0, target))
|
||||
return base
|
||||
|
||||
|
||||
|
||||
@ -64,6 +64,8 @@ class Target:
|
||||
Saved as the day's lift (``_grace_floor.save_lift``), so a credit that
|
||||
lands later is paid out of it first instead of stacking on 20:00.
|
||||
"""
|
||||
if self.resolution.sick:
|
||||
return 0 # earned_time 0.13.0: credits stack on 20:00
|
||||
return self.minutes - self.earned
|
||||
|
||||
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
"""Sick-day shutdown floor, applied live: 20:00 the moment today turns sick.
|
||||
"""Sick-day shutdown, applied live: 20:00 plus the day's gaming pay.
|
||||
|
||||
The daily reset already resolves a sick day with ``earned_time.resolve(...,
|
||||
sick=True)``. But a day is usually marked sick *after* that reset ran --
|
||||
at 10:00 from the lock screen or the phone -- and the reset is stamped once
|
||||
per day. This pass closes the gap: on every 5-minute locker tick, every
|
||||
``--sync-only`` / ``--apply-bonuses`` pass and right after the lock screen's
|
||||
"I'm sick" tap, it lifts tonight's shutdown to
|
||||
``earned_time.sick_shutdown_minutes`` (``max(20:00, current)``, capped at the
|
||||
day's ceiling).
|
||||
"I'm sick" tap, it raises tonight's shutdown to the derived sick target
|
||||
(:func:`sick_target`: 20:00 plus every credit's gaming pay, earned_time 0.13.0).
|
||||
|
||||
The lift is a floor, not a bonus, exactly like the grace floor: it is added
|
||||
to the same ``grace_floor.json`` lift, so a workout credited later is paid
|
||||
out of it first (``_grace_floor.plan_absorb``) and lands on
|
||||
``max(20:00, ladder)`` -- 20:40, not 20:00 + the workout's whole reward.
|
||||
Credits stack on 20:00 (kuhy, 2026-10-17: LeetCode + reading is 22:00, not
|
||||
the 20:10 that 0.11.0's non-stacking floor gave). The target is absolute,
|
||||
re-derived from the ledgers, so on a sick day a live credit raises to it
|
||||
instead of adding (``ShutdownMixin``): the new row is already in the sum.
|
||||
No lift is recorded, so nothing is absorbed.
|
||||
|
||||
Raise-only. Un-marking today does not lower the config until the next
|
||||
daily reset re-derives it.
|
||||
@ -24,18 +24,25 @@ from datetime import date
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._bonus_lock import bonus_lock
|
||||
from screen_locker._constants import GRACE_STATE_FILE
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import hhmm
|
||||
from screen_locker._grace_floor import load_lift, save_lift, tonight_minutes
|
||||
from screen_locker._grace_floor import save_lift, tonight_minutes
|
||||
from screen_locker._shutdown_target import gather
|
||||
from screen_locker._sick import is_sick
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sick_target(day: date, log_file: Path | None) -> int:
|
||||
"""Tonight's sick-day shutdown, re-derived from the ledgers and the log."""
|
||||
return gather(day, log_file).minutes
|
||||
|
||||
|
||||
class SickFloorMixin:
|
||||
"""Lifts tonight's shutdown to the sick floor; composed into ShutdownMixin."""
|
||||
|
||||
@ -48,7 +55,7 @@ class SickFloorMixin:
|
||||
) -> bool: ...
|
||||
|
||||
def _apply_sick_floor(self) -> bool:
|
||||
"""Raise tonight's shutdown to the sick floor if today is sick.
|
||||
"""Raise tonight's shutdown to the sick target if today is sick.
|
||||
|
||||
Returns True when the config was raised on this call.
|
||||
"""
|
||||
@ -62,7 +69,7 @@ class SickFloorMixin:
|
||||
_logger.warning("Sick floor: shutdown config unreadable; not applied")
|
||||
return False
|
||||
current = tonight_minutes(config, day)
|
||||
floor = earned_time.sick_shutdown_minutes(current, day)
|
||||
floor = sick_target(day, getattr(self, "log_file", None))
|
||||
if floor <= current:
|
||||
return False
|
||||
mon_wed, thu_sun, morning = config
|
||||
@ -71,8 +78,7 @@ class SickFloorMixin:
|
||||
):
|
||||
_logger.warning("Sick floor: failed to write shutdown config")
|
||||
return False
|
||||
lift = load_lift(GRACE_STATE_FILE, today) + floor - current
|
||||
save_lift(lift, GRACE_STATE_FILE)
|
||||
save_lift(0, GRACE_STATE_FILE) # stacked: nothing to absorb
|
||||
_logger.info(
|
||||
"Sick floor: shutdown %s -> %s (sick day).", hhmm(current), hhmm(floor)
|
||||
)
|
||||
|
||||
@ -59,16 +59,19 @@ class TestTarget:
|
||||
target = derive(DayInputs(_DAY, _FLAT, first_done=at("18:55"), sick_day=True))
|
||||
assert target.grace is None
|
||||
assert target.resolution.sick is True
|
||||
assert target.minutes == max(SICK_FLOOR, target.earned)
|
||||
# The lift is the sick floor's, so later credits absorb it first.
|
||||
assert target.lift == target.resolution.sick_lift_minutes
|
||||
assert target.lift == SICK_FLOOR - target.earned
|
||||
assert target.minutes == SICK_FLOOR + sum(
|
||||
t.gaming_minutes for t in target.resolution.terms
|
||||
)
|
||||
# earned_time 0.13.0: credits stack on 20:00, so there is no lift.
|
||||
assert target.lift == 0
|
||||
|
||||
def test_a_sick_day_above_20_00_keeps_the_ladder_value(self) -> None:
|
||||
def test_a_sick_day_stacks_gaming_pay_on_20_00(self) -> None:
|
||||
busy = dict.fromkeys(_FLAT, True)
|
||||
target = derive(DayInputs(_DAY, busy, workout_credits=1, sick_day=True))
|
||||
assert target.minutes == target.earned
|
||||
assert target.minutes > SICK_FLOOR
|
||||
assert target.minutes >= target.earned
|
||||
assert target.minutes == min(
|
||||
23 * 60, SICK_FLOOR + sum(t.gaming_minutes for t in target.resolution.terms)
|
||||
)
|
||||
assert target.lift == 0
|
||||
|
||||
def test_gather_keeps_the_sick_floor_when_the_workout_lock_is_kept(self) -> None:
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"""The live sick floor: a day marked sick after its reset is lifted to 20:00.
|
||||
"""The live sick day: tonight is raised to 20:00 plus the day's gaming pay.
|
||||
|
||||
The lift is recorded like the grace lift, so a workout credited afterwards is
|
||||
paid out of it first and lands on max(20:00, ladder) -- not 20:00 plus the
|
||||
workout's whole reward.
|
||||
Since earned_time 0.13.0 credits stack on 20:00 at gaming pay (kuhy,
|
||||
2026-10-17: LeetCode + reading on a sick day is 22:00, not 20:10). The pass
|
||||
raises to the derived target (``_sick_floor.sick_target``) and records no
|
||||
lift, so nothing is absorbed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -17,7 +18,6 @@ import pytest
|
||||
import sickdays
|
||||
|
||||
from screen_locker import _grace_floor, _shutdown, _sick_floor
|
||||
from screen_locker._earned import first_minutes
|
||||
from screen_locker._grace_floor import plan_absorb
|
||||
from screen_locker._shutdown import ShutdownMixin
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
@ -60,6 +60,10 @@ def _on_the_day() -> Iterator[Any]:
|
||||
yield
|
||||
|
||||
|
||||
def _target(minutes: int) -> Any:
|
||||
return patch.object(_sick_floor, "sick_target", return_value=minutes)
|
||||
|
||||
|
||||
class TestApplySickFloor:
|
||||
def test_not_sick_does_nothing(self) -> None:
|
||||
host = Host((18 * 60, 18 * 60, 300))
|
||||
@ -70,58 +74,64 @@ class TestApplySickFloor:
|
||||
"""The 20:00 floor is the day's, not the workout lock's: keep is moot."""
|
||||
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
|
||||
host = Host((18 * 60, 18 * 60 + 30, 300))
|
||||
assert host._apply_sick_floor() is True
|
||||
with _target(_FLOOR):
|
||||
assert host._apply_sick_floor() is True
|
||||
assert host.config == (_FLOOR, _FLOOR, 300)
|
||||
|
||||
def test_marked_sick_lifts_to_20_00_and_records_the_lift(self) -> None:
|
||||
def test_raises_to_the_derived_target_and_records_no_lift(self) -> None:
|
||||
mark_sick(_DAY)
|
||||
host = Host((18 * 60, 18 * 60 + 30, 300))
|
||||
assert host._apply_sick_floor() is True
|
||||
# Saturday reads the Thu-Sun band (18:30): lifted by 90 minutes.
|
||||
assert host.config == (_FLOOR, _FLOOR, 300)
|
||||
assert plan_absorb(0) == (0, 90)
|
||||
host = Host((18 * 60, 20 * 60 + 10, 300))
|
||||
with _target(22 * 60):
|
||||
assert host._apply_sick_floor() is True
|
||||
assert host.config == (22 * 60, 22 * 60, 300)
|
||||
assert plan_absorb(60) == (60, 0)
|
||||
|
||||
def test_already_at_or_above_the_floor_is_left_alone(self) -> None:
|
||||
def test_already_at_or_above_the_target_is_left_alone(self) -> None:
|
||||
mark_sick(_DAY)
|
||||
host = Host((21 * 60, 21 * 60, 300))
|
||||
assert host._apply_sick_floor() is False
|
||||
with _target(21 * 60):
|
||||
assert host._apply_sick_floor() is False
|
||||
assert host.config == (21 * 60, 21 * 60, 300)
|
||||
|
||||
def test_unreadable_config_is_logged(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
mark_sick(_DAY)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with caplog.at_level(logging.WARNING), _target(_FLOOR):
|
||||
assert Host(None)._apply_sick_floor() is False
|
||||
assert "config unreadable" in caplog.text
|
||||
|
||||
def test_failed_write_records_no_lift(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
def test_failed_write_is_logged(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
mark_sick(_DAY)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with caplog.at_level(logging.WARNING), _target(_FLOOR):
|
||||
assert Host((18 * 60, 18 * 60, 300), ok=False)._apply_sick_floor() is False
|
||||
assert "failed to write" in caplog.text
|
||||
assert plan_absorb(0) == (0, 0)
|
||||
|
||||
|
||||
class TestNoStackingOnTop:
|
||||
def test_a_workout_after_the_mark_lands_on_the_ladder_not_above_it(self) -> None:
|
||||
"""Mark sick at 10:00 (18:50 -> 20:00), then a workout: the ladder value."""
|
||||
start = 18 * 60 + 50
|
||||
host = Host((start, start, 300))
|
||||
def test_the_target_is_the_derived_sick_resolution(self) -> None:
|
||||
mark_sick(_DAY)
|
||||
host._apply_sick_floor()
|
||||
host._adjust_shutdown_time_later()
|
||||
ladder = start + first_minutes(earned_time.WORKOUT, _DAY)
|
||||
assert host.config is not None
|
||||
assert host.config[1] == max(_FLOOR, ladder)
|
||||
with patch.object(_sick_floor, "gather") as gather:
|
||||
gather.return_value.minutes = 22 * 60
|
||||
assert _sick_floor.sick_target(_DAY, None) == 22 * 60
|
||||
gather.assert_called_once_with(_DAY, None)
|
||||
|
||||
def test_a_small_credit_is_absorbed_entirely(self) -> None:
|
||||
host = Host((18 * 60, 18 * 60, 300))
|
||||
|
||||
class TestStackingAtGamingPay:
|
||||
def test_a_workout_on_a_sick_day_lands_on_the_target(self) -> None:
|
||||
host = Host((_FLOOR, _FLOOR, 300))
|
||||
mark_sick(_DAY)
|
||||
host._apply_sick_floor()
|
||||
with _target(22 * 60):
|
||||
assert host._adjust_shutdown_time_later() is True
|
||||
assert host.config == (22 * 60, 22 * 60, 300)
|
||||
|
||||
def test_a_flat_credit_is_not_added_on_top_of_the_target(self) -> None:
|
||||
"""The ledger row is already in the derived sum: adding would pay twice."""
|
||||
host = Host((21 * 60, 21 * 60, 300))
|
||||
mark_sick(_DAY)
|
||||
with _target(22 * 60):
|
||||
assert host._adjust_shutdown_time_by(60) is True
|
||||
assert host.config == (22 * 60, 22 * 60, 300)
|
||||
|
||||
def test_not_sick_still_adds(self) -> None:
|
||||
host = Host((19 * 60, 19 * 60, 300))
|
||||
assert host._adjust_shutdown_time_by(60) is True
|
||||
assert host.config is not None
|
||||
assert host.config[1] == _FLOOR
|
||||
assert plan_absorb(0) == (0, 60)
|
||||
assert host.config == (20 * 60, 20 * 60, 300)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user