mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
earned_time 0.15.0: leisure switch file /etc/leisure-watch/leisure_from (C6)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Contract C6 of the leisure rollout (leisure-watch TODO-leisure-rollout.md). leisure_from(path=LEISURE_FROM_PATH) -> date reads the switch file afresh on every call (O_RDONLY|O_NONBLOCK|O_NOFOLLOW, fstat S_ISREG, <= 64 bytes, strict YYYY-MM-DD with one optional newline); any failure is the parked LEISURE_FROM. Never raises. leisure_active(day, path=...) = day >= leisure_from() is the one way consumers ask "is Phase 2 on?". gaming_base_minutes_for / gaming_ceiling_minutes_for / base_for / resolve use it; leisure=True still forces (now a boolean, so a file date later than 2027-01-01 cannot drop the shadow budget back to 5 h). resolve reads the file once, so base and cap never disagree mid-call. Tests redirect the path via an autouse fixture and never touch the real /etc file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
parent
00085a7de6
commit
3afa269e2e
@ -18,14 +18,19 @@ rows in its ledger — and is registered here once.
|
||||
`gaming_minutes` from its `penalty_from` date; each earner done adds its
|
||||
`gaming_minutes` back. Capped at `gaming_ceiling_minutes_for(day)`.
|
||||
|
||||
**Leisure budget (0.14.0, `_leisure.py`).** From `LEISURE_FROM` the budget
|
||||
covers all leisure (games, browsing, video, messaging): base 5 h -> 7 h, cap
|
||||
8 h -> 10 h; pay, penalties and shutdown untouched (fully done day: 180 + 300
|
||||
= 480 before, 300 + 300 = 600 from it). `LEISURE_FROM` is parked at 2027-01-01
|
||||
until Phase 2 sets it to the day after kuhy confirms the shadow week, so every
|
||||
real day still resolves byte-identically. `resolve(..., leisure=True)` (and
|
||||
`base_for(..., leisure=True)`) prices any day with the leisure base and cap:
|
||||
the shadow budget the enforcer shows during that week.
|
||||
**Leisure budget (0.14.0, `_leisure.py`).** From the first leisure day the
|
||||
budget covers all leisure (games, browsing, video, messaging): base 5 h -> 7 h,
|
||||
cap 8 h -> 10 h; pay, penalties and shutdown untouched (full day 480 -> 600).
|
||||
`resolve`/`base_for(..., leisure=True)` price any day so: the shadow budget.
|
||||
|
||||
**The switch file (0.15.0).** The first leisure day is `leisure_from()`: the
|
||||
date in `/etc/leisure-watch/leisure_from` (`LEISURE_FROM_PATH`, one line
|
||||
`YYYY-MM-DD`, root:root 0644), else the parked `LEISURE_FROM` (2027-01-01).
|
||||
Read afresh each call (`O_NOFOLLOW|O_NONBLOCK`, regular file <= 64 bytes,
|
||||
strict format; anything else is parked; never raises), once per `resolve`.
|
||||
`leisure_active(day)` (`day >= leisure_from()`, gaming day) is the ONLY way a
|
||||
consumer asks "is Phase 2 on?"; none reads the file. `leisure-watch go-live`
|
||||
writes it (always tomorrow's date, after its preflight); `--undo` removes it.
|
||||
|
||||
**Shutdown** has two regimes, switched by date (`LADDER_FROM`, 2026-10-10 —
|
||||
never a same-day cut):
|
||||
@ -110,18 +115,12 @@ per-matcher reader `_match.ROW_UNITS`):
|
||||
every 5 minutes) are allowed under the same rules; each write fires
|
||||
screen-locker's `earner-bonus.path`, so per-minute rows rewrite the
|
||||
shutdown schedule once a minute.
|
||||
- Rollout order: no minute row may be written until every consumer process
|
||||
runs 0.8.0. A 0.7.0 reader pays each row as a 15-minute block.
|
||||
|
||||
Days before `TUTOR_FROM` resolve on `EARNERS` exactly as before. Consumers must
|
||||
iterate `earners_for(day)`, never `EARNERS`; `resolve`/`base_for` default to
|
||||
it, and ignore answers for an earner of another day's registry.
|
||||
|
||||
**The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09 only).** Until `TUTOR_FROM`
|
||||
(2026-10-10) the registry is `EARNERS` without `anki` and `automation`: neither
|
||||
penalised nor paid, so the base rises by what they could pay back (nothing
|
||||
done 4 h / 19:00; everything 8 h / 23:00). The tutor was confirmed in 0.7.0
|
||||
(`confirmed_on` 2026-10-09), so its penalty bites from `TUTOR_FROM`.
|
||||
**Gap waiver** (`ANKI_WAIVED_FROM`, 2026-10-09): no anki/automation earner.
|
||||
|
||||
## Gate maturity (0.6.0)
|
||||
|
||||
@ -154,8 +153,8 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
An unwired consumer (no `first_credits`) cuts the tutor from `penalty_from`:
|
||||
19:00 / 3 h.
|
||||
- **CLI:** `python -m earned_time maturity [--json] [--day YYYY-MM-DD]`
|
||||
prints one row per ledger-backed earner of every registry. It is the only
|
||||
code that resolves real paths (`~/<Earner.ledger>`,
|
||||
prints one row per ledger-backed earner of every registry. It and
|
||||
`leisure_from()` alone resolve real paths (`~/<Earner.ledger>`,
|
||||
`/etc/workout-locker/hmac.key`), and it only reads.
|
||||
|
||||
## API
|
||||
@ -165,6 +164,7 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
day) -> int`. `base_for(day=None, earners=None, *, first_credits=None,
|
||||
leisure=False) -> Base` — the day's floors. `gaming_base_minutes_for(day)`,
|
||||
`gaming_ceiling_minutes_for(day) -> int` — the gaming base and cap.
|
||||
`leisure_from(path=LEISURE_FROM_PATH) -> date`, `leisure_active(day, path=...)`.
|
||||
- `done_today(earner, ledger, key_file, *, now=None) -> bool | None`
|
||||
- `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time
|
||||
of the earliest verified credit for `day`, on its matcher's stamp
|
||||
@ -230,8 +230,8 @@ on the day itself is logged and does not count.
|
||||
## Rules the consumers rely on
|
||||
|
||||
- **Minutes everywhere.** Shutdown is minutes after local midnight.
|
||||
- **Paths are passed in.** No reader resolves a real path itself; consumers'
|
||||
test suites redirect their own constants.
|
||||
- **Paths are passed in.** No reader resolves a real path itself, except the
|
||||
switch file: consumer tests patch `earned_time._leisure.LEISURE_FROM_PATH`.
|
||||
- **`None` is "could not check", never "no".** It earns nothing (fail closed),
|
||||
but consumers log it. A missing answer in `resolve()` is logged as well; an
|
||||
unknown earner name raises.
|
||||
@ -243,7 +243,7 @@ on the day itself is logged and does not count.
|
||||
## Install
|
||||
|
||||
```console
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.14.0#subdirectory=earned_time"
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.15.0#subdirectory=earned_time"
|
||||
```
|
||||
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
||||
|
||||
@ -24,7 +24,8 @@ A consumer's whole integration::
|
||||
|
||||
``sick`` (0.11.0) only raises shutdown to at least 20:00; gaming is unchanged.
|
||||
``leisure=True`` (0.14.0) prices gaming with the leisure base and cap
|
||||
(:data:`LEISURE_FROM`) whatever the day: the shadow budget.
|
||||
whatever the day: the shadow budget. The first real leisure day is
|
||||
:func:`leisure_from` (0.15.0, the switch file); ask :func:`leisure_active`.
|
||||
|
||||
How far to trust a gate is computed, not listed: :func:`maturity` (and
|
||||
``python -m earned_time maturity``).
|
||||
@ -63,8 +64,11 @@ from earned_time._leisure import (
|
||||
LEISURE_BASE_MINUTES,
|
||||
LEISURE_CEILING_MINUTES,
|
||||
LEISURE_FROM,
|
||||
LEISURE_FROM_PATH,
|
||||
gaming_base_minutes_for,
|
||||
gaming_ceiling_minutes_for,
|
||||
leisure_active,
|
||||
leisure_from,
|
||||
)
|
||||
from earned_time._maturity import (
|
||||
MATURE_MIN_AGE_DAYS,
|
||||
@ -108,6 +112,7 @@ __all__ = [
|
||||
"LEISURE_BASE_MINUTES",
|
||||
"LEISURE_CEILING_MINUTES",
|
||||
"LEISURE_FROM",
|
||||
"LEISURE_FROM_PATH",
|
||||
"MATURE_MIN_AGE_DAYS",
|
||||
"MATURE_MIN_CREDIT_DAYS",
|
||||
"READING",
|
||||
@ -145,6 +150,8 @@ __all__ = [
|
||||
"gaming_base_minutes_for",
|
||||
"gaming_ceiling_minutes_for",
|
||||
"ladder_for",
|
||||
"leisure_active",
|
||||
"leisure_from",
|
||||
"maturity",
|
||||
"on_ladder",
|
||||
"penalty_start",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The leisure budget: from ``LEISURE_FROM`` gaming time covers all leisure.
|
||||
"""The leisure budget: from ``leisure_from()`` gaming time covers all leisure.
|
||||
|
||||
The daily "gaming" budget becomes a budget for every non-productive activity
|
||||
on the PC (games, browsing, video, messaging), so a fully earned day grows
|
||||
@ -7,41 +7,102 @@ from 8 h to 10 h: the base rises 5 h -> 7 h and the cap 8 h -> 10 h. Earner
|
||||
pay, penalties and the shutdown ladder are untouched, so a day still earns
|
||||
exactly what it did; only the floor and the cap move, by the same 2 h.
|
||||
|
||||
Days before ``LEISURE_FROM`` keep ``GAMING_BASE_MINUTES`` and
|
||||
``GAMING_CEILING_MINUTES``, byte-identical. ``resolve(..., leisure=True)``
|
||||
prices any day as if ``LEISURE_FROM`` had already passed: the shadow budget
|
||||
the enforcer reports during the shadow week, while it still enforces the old.
|
||||
The first leisure day is the switch file ``LEISURE_FROM_PATH`` (0.15.0),
|
||||
written by ``leisure-watch go-live`` (always tomorrow's date); without a
|
||||
readable one it is the parked ``LEISURE_FROM``. Earlier days keep
|
||||
``GAMING_BASE_MINUTES`` and ``GAMING_CEILING_MINUTES``, byte-identical.
|
||||
``resolve(..., leisure=True)`` prices any day with the leisure base and cap:
|
||||
the shadow budget the enforcer reports while it still enforces the old one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
from typing import Final
|
||||
|
||||
from earned_time._policy import GAMING_BASE_MINUTES, GAMING_CEILING_MINUTES
|
||||
|
||||
# Parked far ahead: Phase 2 of the leisure rollout sets it to the day after
|
||||
# kuhy confirms the shadow week, never a same-day cut (2026-09-26).
|
||||
# Parked far ahead: the switch file moves it, never a same-day cut (2026-09-26).
|
||||
LEISURE_FROM: Final = date(2027, 1, 1)
|
||||
# From ``LEISURE_FROM``: 7 h base, 10 h cap (+2 h each).
|
||||
# One line ``YYYY-MM-DD``, root:root 0644 (contract C6 of the leisure rollout).
|
||||
LEISURE_FROM_PATH: Final = Path("/etc/leisure-watch/leisure_from")
|
||||
# From the first leisure day: 7 h base, 10 h cap (+2 h each).
|
||||
LEISURE_BASE_MINUTES: Final = 7 * 60
|
||||
LEISURE_CEILING_MINUTES: Final = 10 * 60
|
||||
|
||||
_MAX_BYTES: Final = 64
|
||||
# Checked before fromisoformat, which also takes "20261020" and "2026-W43-2".
|
||||
_ISO_DAY: Final = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\n?")
|
||||
# NONBLOCK: a FIFO planted there must not hang the root daemon on open().
|
||||
_OPEN_FLAGS: Final = os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||
|
||||
def leisure_day(day: date, *, leisure: bool = False) -> date:
|
||||
"""The day the gaming base and cap are priced on.
|
||||
|
||||
``day`` itself, unless ``leisure``: then no earlier than ``LEISURE_FROM``,
|
||||
so the shadow budget of an old day uses the leisure base and cap.
|
||||
def _read_day(path: Path) -> date | None:
|
||||
"""The date in ``path`` if it is a small regular file holding one; else None."""
|
||||
fd = os.open(path, _OPEN_FLAGS)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
return None
|
||||
raw = os.read(fd, _MAX_BYTES + 1)
|
||||
finally:
|
||||
os.close(fd)
|
||||
text = raw.decode("ascii")
|
||||
if len(raw) > _MAX_BYTES or not _ISO_DAY.fullmatch(text):
|
||||
return None
|
||||
return date.fromisoformat(text.rstrip("\n"))
|
||||
|
||||
|
||||
def leisure_from(path: Path = LEISURE_FROM_PATH) -> date:
|
||||
"""The first leisure day: the switch file's date, else ``LEISURE_FROM``.
|
||||
|
||||
Read afresh on every call (go-live can write it mid-run), never following
|
||||
a symlink or blocking on a FIFO. Anything but a regular file of at most
|
||||
64 bytes holding exactly ``YYYY-MM-DD`` (one optional newline) -- missing,
|
||||
unreadable, garbage, an impossible date -- is the parked ``LEISURE_FROM``.
|
||||
Never raises.
|
||||
"""
|
||||
return max(day, LEISURE_FROM) if leisure else day
|
||||
try:
|
||||
day = _read_day(path)
|
||||
except OSError, ValueError: # UnicodeDecodeError is a ValueError
|
||||
return LEISURE_FROM
|
||||
return LEISURE_FROM if day is None else day
|
||||
|
||||
|
||||
def leisure_active(day: date, path: Path = LEISURE_FROM_PATH) -> bool:
|
||||
"""Is ``day`` (a gaming day) on the leisure budget? ``day >= leisure_from()``.
|
||||
|
||||
The only way a consumer may ask "is Phase 2 on?"; none reads the file.
|
||||
"""
|
||||
return day >= leisure_from(path)
|
||||
|
||||
|
||||
def is_leisure(day: date, *, leisure: bool = False) -> bool:
|
||||
"""``leisure`` (forced: the shadow budget) or ``day`` is a leisure day.
|
||||
|
||||
Looks ``LEISURE_FROM_PATH`` up at call time, so tests can redirect it.
|
||||
"""
|
||||
return leisure or leisure_active(day, LEISURE_FROM_PATH)
|
||||
|
||||
|
||||
def base_minutes(*, leisure: bool) -> int:
|
||||
"""The gaming base before penalties: 7 h on the leisure budget, else 5 h."""
|
||||
return LEISURE_BASE_MINUTES if leisure else GAMING_BASE_MINUTES
|
||||
|
||||
|
||||
def ceiling_minutes(*, leisure: bool) -> int:
|
||||
"""The gaming cap: 10 h on the leisure budget, else 8 h."""
|
||||
return LEISURE_CEILING_MINUTES if leisure else GAMING_CEILING_MINUTES
|
||||
|
||||
|
||||
def gaming_base_minutes_for(day: date) -> int:
|
||||
"""The gaming base before penalties: 7 h from ``LEISURE_FROM``, else 5 h."""
|
||||
return LEISURE_BASE_MINUTES if day >= LEISURE_FROM else GAMING_BASE_MINUTES
|
||||
"""The gaming base before penalties: 7 h from ``leisure_from()``, else 5 h."""
|
||||
return base_minutes(leisure=is_leisure(day))
|
||||
|
||||
|
||||
def gaming_ceiling_minutes_for(day: date) -> int:
|
||||
"""The gaming cap: 10 h from ``LEISURE_FROM``, else 8 h."""
|
||||
return LEISURE_CEILING_MINUTES if day >= LEISURE_FROM else GAMING_CEILING_MINUTES
|
||||
"""The gaming cap: 10 h from ``leisure_from()``, else 8 h."""
|
||||
return ceiling_minutes(leisure=is_leisure(day))
|
||||
|
||||
@ -26,11 +26,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._ladder import on_ladder, shutdown_ceiling_for
|
||||
from earned_time._leisure import (
|
||||
gaming_base_minutes_for,
|
||||
gaming_ceiling_minutes_for,
|
||||
leisure_day,
|
||||
)
|
||||
from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure
|
||||
from earned_time._maturity import confirmed_start, penalty_start
|
||||
from earned_time._policy import SHUTDOWN_BASE_MINUTES, Earner
|
||||
from earned_time._registry import all_earners, earners_for
|
||||
@ -102,13 +98,24 @@ def base_for(
|
||||
``first_credits`` (per earner name) delays each penalty, and the ladder
|
||||
rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
|
||||
The gaming base is :func:`gaming_base_minutes_for` the day, or with
|
||||
``leisure`` the leisure base whatever the date (:func:`leisure_day`).
|
||||
``leisure`` the leisure base whatever the date (:func:`is_leisure`).
|
||||
"""
|
||||
target = day or _today()
|
||||
registry = earners_for(target) if earners is None else earners
|
||||
on = is_leisure(target, leisure=leisure)
|
||||
return _base(target, registry, first_credits, on=on)
|
||||
|
||||
|
||||
def _base(
|
||||
target: date,
|
||||
registry: tuple[Earner, ...],
|
||||
first_credits: Mapping[str, date | None] | None,
|
||||
*,
|
||||
on: bool,
|
||||
) -> Base:
|
||||
penalised = [e for e in registry if _penalised(e, target, first_credits)]
|
||||
return Base(
|
||||
gaming_minutes=gaming_base_minutes_for(leisure_day(target, leisure=leisure))
|
||||
gaming_minutes=base_minutes(leisure=on)
|
||||
- sum(e.max_gaming_minutes for e in penalised),
|
||||
shutdown_minutes=_shutdown_floor(target, registry, penalised),
|
||||
)
|
||||
@ -179,8 +186,8 @@ def resolve(
|
||||
is 20:00 plus the terms' gaming pay
|
||||
(:func:`~earned_time._sick.sick_shutdown_from`, 0.13.0).
|
||||
Gaming and every term are unchanged.
|
||||
leisure: Price gaming as if ``LEISURE_FROM`` had passed (0.14.0): the
|
||||
leisure base and cap whatever the day -- the shadow budget.
|
||||
leisure: Price gaming as a leisure day (0.14.0): the leisure base and
|
||||
cap whatever the day and the switch file say -- the shadow budget.
|
||||
Terms, penalties and shutdown are unchanged.
|
||||
|
||||
Returns:
|
||||
@ -193,7 +200,9 @@ def resolve(
|
||||
target = day or _today()
|
||||
registry = earners_for(target) if earners is None else earners
|
||||
_check_registered(answers, registry)
|
||||
base = base_for(target, registry, first_credits=first_credits, leisure=leisure)
|
||||
# One switch-file read per resolve, so base and cap can never disagree.
|
||||
on = is_leisure(target, leisure=leisure)
|
||||
base = _base(target, registry, first_credits, on=on)
|
||||
terms = [_term(item, answers, target) for item in registry]
|
||||
gaming = base.gaming_minutes + sum(t.gaming_minutes for t in terms)
|
||||
earned = min(
|
||||
@ -205,9 +214,7 @@ def resolve(
|
||||
day=target,
|
||||
base=base,
|
||||
terms=tuple(terms),
|
||||
gaming_minutes=min(
|
||||
gaming_ceiling_minutes_for(leisure_day(target, leisure=leisure)), gaming
|
||||
),
|
||||
gaming_minutes=min(ceiling_minutes(leisure=on), gaming),
|
||||
shutdown_minutes=shutdown,
|
||||
sick=sick,
|
||||
sick_lift_minutes=shutdown - earned,
|
||||
|
||||
@ -13,6 +13,14 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_switch_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Point the leisure switch file at a missing path, never the real /etc one."""
|
||||
monkeypatch.setattr(
|
||||
"earned_time._leisure.LEISURE_FROM_PATH", tmp_path / "no_leisure_from"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key_file(tmp_path: Path) -> Path:
|
||||
"""The test HMAC key, written where a test can point ``maturity`` at it."""
|
||||
|
||||
168
earned_time/earned_time/tests/test_leisure_switch.py
Normal file
168
earned_time/earned_time/tests/test_leisure_switch.py
Normal file
@ -0,0 +1,168 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The leisure switch file (0.15.0): ``/etc/leisure-watch/leisure_from``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import (
|
||||
LEISURE_FROM,
|
||||
LEISURE_FROM_PATH,
|
||||
TUTOR_EARNERS,
|
||||
UNIFIED_FROM,
|
||||
base_for,
|
||||
gaming_base_minutes_for,
|
||||
gaming_ceiling_minutes_for,
|
||||
leisure_active,
|
||||
leisure_from,
|
||||
resolve,
|
||||
)
|
||||
|
||||
DAY = date(2026, 10, 20)
|
||||
FULL = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60}
|
||||
NONE = dict.fromkeys(FULL, 0)
|
||||
|
||||
|
||||
def _switch(tmp_path: Path, content: bytes) -> Path:
|
||||
path = tmp_path / "leisure_from"
|
||||
path.write_bytes(content)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""The module's switch path, holding ``DAY`` (go-live wrote it)."""
|
||||
path = _switch(tmp_path, b"2026-10-20\n")
|
||||
monkeypatch.setattr("earned_time._leisure.LEISURE_FROM_PATH", path)
|
||||
return path
|
||||
|
||||
|
||||
def test_default_path_is_the_etc_switch_file() -> None:
|
||||
assert Path("/etc/leisure-watch/leisure_from") == LEISURE_FROM_PATH
|
||||
for func in (leisure_from, leisure_active):
|
||||
assert inspect.signature(func).parameters["path"].default == LEISURE_FROM_PATH
|
||||
|
||||
|
||||
def test_missing_file_is_parked(tmp_path: Path) -> None:
|
||||
assert leisure_from(tmp_path / "absent") == LEISURE_FROM
|
||||
assert not leisure_active(DAY, tmp_path / "absent")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", [b"2026-10-20", b"2026-10-20\n"])
|
||||
def test_valid_date_is_read(tmp_path: Path, content: bytes) -> None:
|
||||
assert leisure_from(_switch(tmp_path, content)) == DAY
|
||||
|
||||
|
||||
def test_file_date_is_the_first_leisure_day(tmp_path: Path) -> None:
|
||||
path = _switch(tmp_path, b"2026-10-21\n") # go-live on the 20th: tomorrow
|
||||
assert not leisure_active(DAY, path)
|
||||
assert leisure_active(DAY + timedelta(days=1), path)
|
||||
assert leisure_active(DAY + timedelta(days=2), path)
|
||||
assert leisure_active(DAY, _switch(tmp_path, b"2026-10-20\n")) # today
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
b"",
|
||||
b"garbage\n",
|
||||
b"2026-13-01\n",
|
||||
b"2026-02-30\n",
|
||||
b"20261020\n",
|
||||
b"2026-W43-2\n",
|
||||
b" 2026-10-20\n",
|
||||
b"2026-10-20\n\n",
|
||||
b"2026-10-20 \n",
|
||||
b"2026-10-20\r\n",
|
||||
b"2026-10-\xef\xbc\x920\n", # fullwidth digit two
|
||||
b"\xff\xfe2026-10-20",
|
||||
],
|
||||
)
|
||||
def test_garbage_is_parked(tmp_path: Path, content: bytes) -> None:
|
||||
assert leisure_from(_switch(tmp_path, content)) == LEISURE_FROM
|
||||
|
||||
|
||||
def test_oversize_file_is_parked(tmp_path: Path) -> None:
|
||||
assert leisure_from(_switch(tmp_path, b"2026-10-20\n" + b"\n" * 54)) == LEISURE_FROM
|
||||
assert leisure_from(_switch(tmp_path, b"2026-10-20" + b" " * 54)) == LEISURE_FROM
|
||||
|
||||
|
||||
def test_fifo_is_parked_without_blocking(tmp_path: Path) -> None:
|
||||
fifo = tmp_path / "fifo"
|
||||
os.mkfifo(fifo)
|
||||
assert leisure_from(fifo) == LEISURE_FROM
|
||||
|
||||
|
||||
def test_directory_is_parked(tmp_path: Path) -> None:
|
||||
assert leisure_from(tmp_path) == LEISURE_FROM
|
||||
|
||||
|
||||
def test_symlink_is_parked_even_to_a_valid_file(tmp_path: Path) -> None:
|
||||
target = _switch(tmp_path, b"2026-10-20\n")
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(target)
|
||||
assert leisure_from(target) == DAY
|
||||
assert leisure_from(link) == LEISURE_FROM
|
||||
|
||||
|
||||
def test_unreadable_file_is_parked(tmp_path: Path) -> None:
|
||||
path = _switch(tmp_path, b"2026-10-20\n")
|
||||
path.chmod(0)
|
||||
try:
|
||||
if os.access(path, os.R_OK): # pragma: no cover - running as root
|
||||
pytest.skip("root reads a 0000 file")
|
||||
assert leisure_from(path) == LEISURE_FROM
|
||||
finally:
|
||||
path.chmod(0o644)
|
||||
|
||||
|
||||
def test_file_appearing_mid_run_is_seen(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = tmp_path / "leisure_from"
|
||||
monkeypatch.setattr("earned_time._leisure.LEISURE_FROM_PATH", path)
|
||||
assert gaming_base_minutes_for(DAY) == 300
|
||||
path.write_bytes(b"2026-10-20\n")
|
||||
assert gaming_base_minutes_for(DAY) == 420
|
||||
path.unlink()
|
||||
assert gaming_base_minutes_for(DAY) == 300
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("live")
|
||||
def test_base_and_ceiling_follow_the_file() -> None:
|
||||
eve = DAY - timedelta(days=1)
|
||||
assert gaming_base_minutes_for(eve) == 300
|
||||
assert gaming_ceiling_minutes_for(eve) == 480
|
||||
assert gaming_base_minutes_for(DAY) == 420
|
||||
assert gaming_ceiling_minutes_for(DAY) == 600
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("live")
|
||||
def test_resolve_and_base_for_follow_the_file() -> None:
|
||||
eve = DAY - timedelta(days=1)
|
||||
assert base_for(eve).gaming_minutes == 180
|
||||
assert base_for(DAY).gaming_minutes == 300
|
||||
assert resolve(NONE, eve, TUTOR_EARNERS).gaming_minutes == 180
|
||||
assert resolve(FULL, eve, TUTOR_EARNERS).gaming_minutes == 480
|
||||
assert resolve(NONE, DAY, TUTOR_EARNERS).gaming_minutes == 300
|
||||
assert resolve(FULL, DAY, TUTOR_EARNERS).gaming_minutes == 600
|
||||
|
||||
|
||||
def test_leisure_flag_still_forces_past_a_later_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = _switch(tmp_path, b"2027-06-01\n")
|
||||
monkeypatch.setattr("earned_time._leisure.LEISURE_FROM_PATH", path)
|
||||
assert base_for(UNIFIED_FROM, leisure=True).gaming_minutes == 300
|
||||
assert (
|
||||
resolve(NONE, UNIFIED_FROM, TUTOR_EARNERS, leisure=True).gaming_minutes == 300
|
||||
)
|
||||
assert (
|
||||
resolve(FULL, UNIFIED_FROM, TUTOR_EARNERS, leisure=True).gaming_minutes == 600
|
||||
)
|
||||
assert resolve(FULL, date(2027, 1, 2), TUTOR_EARNERS).gaming_minutes == 480
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user