mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
LeetCode hour needs the day paid in full (earned_time 0.18.0); gate_off read live from leetcode-guard
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d2a684ebf9
commit
59a110e5c3
@ -110,8 +110,8 @@ repos:
|
||||
- --disable-error-code=list-item
|
||||
additional_dependencies:
|
||||
- types-requests
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time
|
||||
- leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.2
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time
|
||||
- leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.3
|
||||
|
||||
# ===========================================================================
|
||||
# PYLINT
|
||||
@ -131,8 +131,8 @@ repos:
|
||||
# (steam_backlog_enforcer._mcp_server).
|
||||
additional_dependencies:
|
||||
- aiohttp==3.14.5
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time
|
||||
- leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.2
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time
|
||||
- leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.3
|
||||
- howlongtobeatpy==1.0.24
|
||||
- mcp==2.3.0
|
||||
- pytest
|
||||
|
||||
@ -16,11 +16,11 @@ dependencies = [
|
||||
"websockets==17.3",
|
||||
# The earner registry (gaming budget terms); stdlib-only, so the root
|
||||
# daemon imports it from the desktop user's site-packages.
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time",
|
||||
# The shared /proc game scan and the signed leisure facts (shadow budget,
|
||||
# contract C4); stdlib-only, imported by root from the user site like
|
||||
# earned-time.
|
||||
"leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.2",
|
||||
"leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.3",
|
||||
]
|
||||
|
||||
# Optional extras. The `mcp` group is ONLY needed by the MCP server
|
||||
|
||||
@ -5,9 +5,9 @@ aiohttp==3.14.5
|
||||
bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.17.0
|
||||
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time
|
||||
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time
|
||||
howlongtobeatpy==1.0.24
|
||||
leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.2
|
||||
leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.3
|
||||
mcp==2.3.0
|
||||
mypy==2.4.0
|
||||
pre-commit==4.7.0
|
||||
|
||||
@ -21,8 +21,13 @@ strictly more access than the endpoint does. Both carry the same staleness (see
|
||||
below); the fallback is not a fresher source.
|
||||
|
||||
**Staleness.** leetcode-guard's one-minute harvester credits a solve within
|
||||
about a minute, even with no lock window up. A day paid ahead from banked
|
||||
credits counts like a solve (``earned_time`` >= 0.17.0, kuhy 2026-10-23).
|
||||
about a minute, even with no lock window up.
|
||||
|
||||
**What earns (``earned_time`` >= 0.18.0, kuhy 2026-10-24).** Only today's
|
||||
signed ``leetcode`` charge -- the day paid in full -- earns the hour; one solve
|
||||
of a multi-credit day does not. The exception is a day the gate stands down
|
||||
(free or sick): there one verified solve earns. ``gate_off`` is read live from
|
||||
leetcode-guard's endpoint, and an unreadable answer means the gate is on.
|
||||
|
||||
Reading and verifying the ledger itself lives in
|
||||
:mod:`steam_backlog_enforcer._leetcode_ledger`; this module owns the two
|
||||
@ -59,8 +64,8 @@ def _fetch_leetcode_today(url: str) -> bool:
|
||||
url: The status endpoint, e.g. ``http://127.0.0.1:8771/api/status``.
|
||||
|
||||
Returns:
|
||||
Whether an accepted submission is recorded for today, or today was
|
||||
paid from banked credits.
|
||||
Whether today was paid in full from verified credits, or -- on a day
|
||||
the gate stands down -- whether a submission was accepted.
|
||||
|
||||
Raises:
|
||||
OSError: The endpoint could not be reached.
|
||||
@ -73,9 +78,38 @@ def _fetch_leetcode_today(url: str) -> bool:
|
||||
# The server answered, but said it could not look. That is not a "no".
|
||||
msg = f"leetcode-guard could not check: {block.get('reason', 'no reason')}"
|
||||
raise ValueError(msg)
|
||||
# A day paid ahead from banked credits earns the hour like a solve
|
||||
# (leetcode-guard >= 2026-10-23); an older server has no such field.
|
||||
return bool(block["solved_today"]) or bool(block.get("settled_by_credit"))
|
||||
# One solve of a five-credit day is not a done day (kuhy, 2026-10-24):
|
||||
# only the charge that pays the price earns, unless the gate stands down
|
||||
# today. An older server has neither field, which reads as "no hour".
|
||||
if block.get("gate_off") is True:
|
||||
return bool(block["solved_today"])
|
||||
return bool(block.get("settled_by_credit"))
|
||||
|
||||
|
||||
def gate_off_now(url: str) -> bool:
|
||||
"""Whether leetcode-guard says its gate stands down today (free or sick).
|
||||
|
||||
This root daemon has neither the free-day nor the sick-day pool, so it
|
||||
asks the endpoint, live, every time the memo misses. Anything but an
|
||||
explicit ``true`` -- an unreachable server, an older one without the
|
||||
field -- means the gate is on: one solve then earns nothing.
|
||||
|
||||
Args:
|
||||
url: The status endpoint.
|
||||
|
||||
Returns:
|
||||
The published ``leetcode.gate_off``, or False when it cannot be read.
|
||||
"""
|
||||
try:
|
||||
return get_status(url)["leetcode"].get("gate_off") is True
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
|
||||
logger.warning(
|
||||
"Could not ask %s whether the LeetCode gate is off (%s); "
|
||||
"treating it as on, so only a fully paid day earns",
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def leetcode_solved_today(config: Config) -> bool | None:
|
||||
@ -98,7 +132,7 @@ def leetcode_solved_today(config: Config) -> bool | None:
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
answer = read_ledger_solved_today(path)
|
||||
answer = read_ledger_solved_today(path, gate_off=gate_off_now(url))
|
||||
if answer is None:
|
||||
answer = _fallback_to_endpoint(url, path)
|
||||
if answer is None:
|
||||
|
||||
@ -5,10 +5,9 @@ the same one screen-locker uses; ``test_leetcode_ledger.py`` pins its HMAC
|
||||
canonicalisation to a fixed vector. gatelock itself is not imported: it pulls
|
||||
in tkinter, and this daemon runs headless as root.
|
||||
|
||||
Only ``credit`` entries count. A ``seen`` entry is first-run seeding worth
|
||||
zero, and a ``charge`` proves only that the day was *settled* -- which also
|
||||
happens from banked credit, from the escape hatch and from a classified
|
||||
outage, so it can never stand in for a solve.
|
||||
Since earned_time 0.18.0 only today's signed ``leetcode`` ``charge`` (the day
|
||||
paid in full) earns. A bare ``credit`` earns only when ``gate_off`` is true (a
|
||||
free or sick day), and a ``seen`` entry is first-run seeding worth zero.
|
||||
|
||||
The solve time is ``detail["submitted_at"]``, LeetCode's own timestamp, not the
|
||||
entry's ``day``: ``day`` is stamped by the *harvesting* run, so a problem
|
||||
@ -32,14 +31,19 @@ logger = logging.getLogger(__name__)
|
||||
HMAC_KEY_FILE: Final = Path("/etc/workout-locker/hmac.key")
|
||||
|
||||
|
||||
def read_ledger_solved_today(path: Path) -> bool | None:
|
||||
"""Read leetcode-guard's ledger and count today's verified credits.
|
||||
def read_ledger_solved_today(path: Path, *, gate_off: bool = False) -> bool | None:
|
||||
"""Read leetcode-guard's ledger: was today paid in full?
|
||||
|
||||
Args:
|
||||
path: The ledger file.
|
||||
gate_off: Whether the gate stands down today (asked live). Only then
|
||||
does one verified solve earn; otherwise only the signed charge
|
||||
that covers the day's whole price does (earned_time 0.18.0).
|
||||
|
||||
Returns:
|
||||
True or False when the ledger could be read and verified, ``None`` when
|
||||
it could not -- which is never "nothing was solved".
|
||||
"""
|
||||
return earned_time.done_today(earned_time.LEETCODE, path, HMAC_KEY_FILE)
|
||||
return earned_time.done_today(
|
||||
earned_time.LEETCODE, path, HMAC_KEY_FILE, gate_off=gate_off
|
||||
)
|
||||
|
||||
@ -128,7 +128,14 @@ class TestFetchLeetcodeToday:
|
||||
def test_a_good_answer_is_read(self) -> None:
|
||||
"""The happy path returns the flag."""
|
||||
payload = json.dumps(
|
||||
{"leetcode": {"checked": True, "solved_today": True, "reason": "1"}}
|
||||
{
|
||||
"leetcode": {
|
||||
"checked": True,
|
||||
"solved_today": True,
|
||||
"settled_by_credit": True,
|
||||
"reason": "1",
|
||||
}
|
||||
}
|
||||
).encode()
|
||||
with patch(f"{_API}.http.client.HTTPConnection") as conn:
|
||||
resp = conn.return_value.getresponse.return_value
|
||||
|
||||
@ -6,6 +6,10 @@ in tkinter, and the enforcer runs headless as root).
|
||||
``TestSignatureCanonicalisation`` pins it to a fixed vector so the duplication
|
||||
cannot drift silently -- if leetcode-guard ever changes how it signs, that test
|
||||
fails instead of every credit quietly ceasing to count.
|
||||
|
||||
Every read here passes ``gate_off=True``: these pin how a credit row is
|
||||
parsed, which only decides on a day the gate stands down. On any other day
|
||||
only the paid-in-full charge earns (``test_leetcode_paid_day.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -64,7 +68,10 @@ class TestSignatureCanonicalisation:
|
||||
assert key_file.exists()
|
||||
entry = credit(when=datetime.now().astimezone())
|
||||
entry["amount"] = 99
|
||||
assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is False
|
||||
assert (
|
||||
read_ledger_solved_today(write_ledger(tmp_path, [entry]), gate_off=True)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_a_missing_signature_fails(self, tmp_path: Path, key_file: Path) -> None:
|
||||
"""An entry with no hmac field is not a signed entry.
|
||||
@ -76,7 +83,10 @@ class TestSignatureCanonicalisation:
|
||||
assert key_file.exists()
|
||||
entry = credit(when=datetime.now().astimezone())
|
||||
del entry["hmac"]
|
||||
assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is False
|
||||
assert (
|
||||
read_ledger_solved_today(write_ledger(tmp_path, [entry]), gate_off=True)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
class TestReadingTheLedger:
|
||||
@ -91,7 +101,7 @@ class TestReadingTheLedger:
|
||||
"""
|
||||
assert key_file.exists()
|
||||
ledger = write_ledger(tmp_path, [credit(when=datetime.now().astimezone())])
|
||||
assert read_ledger_solved_today(ledger) is True
|
||||
assert read_ledger_solved_today(ledger, gate_off=True) is True
|
||||
|
||||
def test_a_credit_yesterday_does_not(self, tmp_path: Path, key_file: Path) -> None:
|
||||
"""Yesterday's solve bought yesterday's hour, not today's.
|
||||
@ -103,7 +113,7 @@ class TestReadingTheLedger:
|
||||
assert key_file.exists()
|
||||
yesterday = datetime.now().astimezone() - timedelta(days=1)
|
||||
ledger = write_ledger(tmp_path, [credit(when=yesterday)])
|
||||
assert read_ledger_solved_today(ledger) is False
|
||||
assert read_ledger_solved_today(ledger, gate_off=True) is False
|
||||
|
||||
def test_charges_and_seen_entries_do_not_count(
|
||||
self, tmp_path: Path, key_file: Path
|
||||
@ -141,7 +151,10 @@ class TestReadingTheLedger:
|
||||
}
|
||||
),
|
||||
]
|
||||
assert read_ledger_solved_today(write_ledger(tmp_path, rows)) is False
|
||||
assert (
|
||||
read_ledger_solved_today(write_ledger(tmp_path, rows), gate_off=True)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_a_forged_credit_does_not_count(
|
||||
self, tmp_path: Path, key_file: Path
|
||||
@ -155,7 +168,10 @@ class TestReadingTheLedger:
|
||||
assert key_file.exists()
|
||||
forged = credit(when=datetime.now().astimezone())
|
||||
forged["hmac"] = "00" * 32
|
||||
assert read_ledger_solved_today(write_ledger(tmp_path, [forged])) is False
|
||||
assert (
|
||||
read_ledger_solved_today(write_ledger(tmp_path, [forged]), gate_off=True)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_a_missing_submitted_at_falls_back_to_the_day_key(
|
||||
self, tmp_path: Path, key_file: Path
|
||||
@ -179,7 +195,10 @@ class TestReadingTheLedger:
|
||||
"detail": {"source": "leetcode"},
|
||||
}
|
||||
)
|
||||
assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is True
|
||||
assert (
|
||||
read_ledger_solved_today(write_ledger(tmp_path, [entry]), gate_off=True)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_an_unparsable_submitted_at_is_reported(
|
||||
self, tmp_path: Path, key_file: Path, caplog: pytest.LogCaptureFixture
|
||||
@ -196,5 +215,8 @@ class TestReadingTheLedger:
|
||||
entry = credit(when=now, entry_id="ac:3")
|
||||
entry["detail"]["submitted_at"] = "not-a-number"
|
||||
entry = sign({k: v for k, v in entry.items() if k != "hmac"})
|
||||
assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is True
|
||||
assert (
|
||||
read_ledger_solved_today(write_ledger(tmp_path, [entry]), gate_off=True)
|
||||
is True
|
||||
)
|
||||
assert "unparsable submitted_at" in caplog.text
|
||||
|
||||
108
steam_backlog_enforcer/tests/test_leetcode_paid_day.py
Normal file
108
steam_backlog_enforcer/tests/test_leetcode_paid_day.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""The LeetCode gaming hour needs the day paid in full (earned_time 0.18.0).
|
||||
|
||||
kuhy, 2026-10-24: Saturday cost 5 credits, one problem was solved, and the
|
||||
+1 h gaming paid anyway. Only the signed ``leetcode`` charge that covers the
|
||||
day earns while the gate is on; on a day the gate stands down (free, or sick
|
||||
without ``leetcode``) one solve earns. This daemon runs as root without the
|
||||
free-day and sick-day pools, so it asks leetcode-guard's endpoint for the
|
||||
live ``gate_off`` fact -- and an unreachable endpoint means "on" (no hour).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _leetcode_bonus, _leetcode_ledger
|
||||
from steam_backlog_enforcer._leetcode_bonus import leetcode_solved_today, reset_cache
|
||||
from steam_backlog_enforcer.config import Config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
_PKG = "steam_backlog_enforcer._leetcode_bonus"
|
||||
_API = "steam_backlog_enforcer._status_api"
|
||||
_URL = "http://127.0.0.1:8771/api/status"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache() -> Iterator[None]:
|
||||
reset_cache()
|
||||
yield
|
||||
reset_cache()
|
||||
|
||||
|
||||
def _serve(block: dict[str, bool]) -> object:
|
||||
"""Patch the endpoint to answer ``block`` (checked) with a 200."""
|
||||
payload = json.dumps({"leetcode": {"checked": True, **block}}).encode()
|
||||
conn = patch(f"{_API}.http.client.HTTPConnection")
|
||||
mock = conn.start()
|
||||
resp = mock.return_value.getresponse.return_value
|
||||
resp.status = 200
|
||||
resp.read.return_value = payload
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("block", "expected"),
|
||||
[
|
||||
# One solve of five, gate on: no hour -- the 2026-10-24 bug.
|
||||
({"solved_today": True, "settled_by_credit": False, "gate_off": False}, False),
|
||||
({"solved_today": True, "settled_by_credit": False}, False),
|
||||
({"solved_today": True, "settled_by_credit": True, "gate_off": False}, True),
|
||||
# A stood-down day has no price: one solve earns, no solve does not.
|
||||
({"solved_today": True, "settled_by_credit": False, "gate_off": True}, True),
|
||||
({"solved_today": False, "settled_by_credit": False, "gate_off": True}, False),
|
||||
],
|
||||
)
|
||||
def test_the_fallback_pays_only_a_paid_day(
|
||||
block: dict[str, bool], *, expected: bool
|
||||
) -> None:
|
||||
conn = _serve(block)
|
||||
try:
|
||||
assert _leetcode_bonus._fetch_leetcode_today(_URL) is expected
|
||||
finally:
|
||||
conn.stop()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("off", [True, False])
|
||||
def test_gate_off_is_read_from_the_endpoint(*, off: bool) -> None:
|
||||
conn = _serve({"solved_today": False, "gate_off": off})
|
||||
try:
|
||||
assert _leetcode_bonus.gate_off_now(_URL) is off
|
||||
finally:
|
||||
conn.stop()
|
||||
|
||||
|
||||
def test_an_unreachable_endpoint_means_the_gate_is_on() -> None:
|
||||
with patch(f"{_PKG}.get_status", side_effect=OSError("refused")):
|
||||
assert _leetcode_bonus.gate_off_now(_URL) is False
|
||||
|
||||
|
||||
def test_an_old_endpoint_without_the_field_means_on() -> None:
|
||||
conn = _serve({"solved_today": True})
|
||||
try:
|
||||
assert _leetcode_bonus.gate_off_now(_URL) is False
|
||||
finally:
|
||||
conn.stop()
|
||||
|
||||
|
||||
def test_the_ledger_read_is_told_the_live_answer() -> None:
|
||||
with (
|
||||
patch(f"{_PKG}.gate_off_now", return_value=True),
|
||||
patch(f"{_PKG}.read_ledger_solved_today", return_value=True) as read,
|
||||
):
|
||||
assert leetcode_solved_today(Config()) is True
|
||||
assert read.call_args.kwargs["gate_off"] is True
|
||||
|
||||
|
||||
def test_the_ledger_reader_passes_it_on(tmp_path: Path) -> None:
|
||||
with patch.object(
|
||||
_leetcode_ledger.earned_time, "done_today", return_value=True
|
||||
) as done:
|
||||
_leetcode_ledger.read_ledger_solved_today(tmp_path / "l.json", gate_off=True)
|
||||
assert done.call_args.kwargs["gate_off"] is True
|
||||
Loading…
Reference in New Issue
Block a user