From 17e300af0a0ddd2322a1a38f3155de57ab26588d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:38:48 +0100 Subject: [PATCH] focus_project: pin backing edge cases and pay Tests only: sql_drills/leetcode beats back, other-project and browser beats don't, submissions back only leetcode projects, the cutover midnight, a tutor chain leaves earlier reading unbacked; earned_time pays 0 units for a timer-only post-cutover day (real focus_project). Todo: fda71ed1 Co-Authored-By: Claude Opus 5.5 --- .../earned_time/tests/test_focus_backing.py | 88 ++++++++++++ focus_project/focus_project/minutes.py | 2 +- .../focus_project/tests/test_backing_edges.py | 131 ++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 earned_time/earned_time/tests/test_focus_backing.py create mode 100644 focus_project/focus_project/tests/test_backing_edges.py diff --git a/earned_time/earned_time/tests/test_focus_backing.py b/earned_time/earned_time/tests/test_focus_backing.py new file mode 100644 index 0000000..9594e14 --- /dev/null +++ b/earned_time/earned_time/tests/test_focus_backing.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Regression: a timer-only focus day after the anti-cheat cutover pays 0. + +Runs the REAL ``focus_project`` (the sibling package of this monorepo): +timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time +itself does not change; this pins that the gate reaches pay. +""" + +from __future__ import annotations + +from datetime import date, datetime +import importlib +import json +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +import pytest + +from earned_time import credit_units, earners_for + +if TYPE_CHECKING: + from types import ModuleType + +DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM +_SIBLING = Path(__file__).resolve().parents[3] / "focus_project" + + +def at(hour: int, minute: int) -> float: + return datetime(2026, 11, 9, hour, minute).astimezone().timestamp() + + +@pytest.fixture +def real_fp( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path +) -> ModuleType: + """The real focus_project on redirected state (HOME, data dir, key).""" + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data")) + monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file)) + monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json")) + monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake + monkeypatch.syspath_prepend(str(_SIBLING)) + fp = importlib.import_module("focus_project") + fp.create_project( + "hr", + name="HR", + start=DAY, + end=DAY, + replaces=["leetcode"], + daily_minutes=120, + unlock_minutes=30, + ) + return fp + + +def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None: + log = tmp_path / "data" / "activity.jsonl" + log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8") + harvest = importlib.import_module("focus_project.harvest") + assert harvest.harvest(now=at(23, 0)) == 0 + (item,) = (e for e in earners_for(DAY) if e.name == "focus") + return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY) + + +def row(source: str, kind: str, ts: float) -> dict[str, object]: + return {"ts": ts, "source": source, "kind": kind, "project": "hr"} + + +TIMER = ( + row("timer", "start", at(9, 0)), + row("timer", "checkin", at(9, 5)), + row("timer", "stop", at(9, 10)), +) + + +def test_timer_only_day_pays_zero( + real_fp: ModuleType, tmp_path: Path, key_file: Path +) -> None: + assert date(2026, 11, 8) == real_fp.BACKING_FROM + assert pay(real_fp, tmp_path, key_file, *TIMER) == 0 + + +def test_backed_timer_day_still_pays( + real_fp: ModuleType, tmp_path: Path, key_file: Path +) -> None: + beat = row("tutor", "heartbeat", at(9, 5)) + assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10 diff --git a/focus_project/focus_project/minutes.py b/focus_project/focus_project/minutes.py index 062c5b6..ee89661 100644 --- a/focus_project/focus_project/minutes.py +++ b/focus_project/focus_project/minutes.py @@ -9,7 +9,7 @@ clipped at "now", so an event stamped ahead of the clock (a fast phone clock) earns only once the real clock reaches it. An unreadable registry reads as 0 (logged): fail closed. -Unbacked minutes (0.8.0): timer time the anti-cheat gate refused +Unbacked minutes (0.10.0): timer time the anti-cheat gate refused (:mod:`focus_project.sources.activity`) and no other source covers, on the local day up to "now"; carry and the cap play no part. Shown, never paid. """ diff --git a/focus_project/focus_project/tests/test_backing_edges.py b/focus_project/focus_project/tests/test_backing_edges.py new file mode 100644 index 0000000..2a2de00 --- /dev/null +++ b/focus_project/focus_project/tests/test_backing_edges.py @@ -0,0 +1,131 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains. + +The core rule lives in ``test_backing.py``; these pin the edges the spec +named (C5-C7) with leetcode-ledger and other-project fixtures. +""" + +from __future__ import annotations + +from datetime import date +import json +from typing import TYPE_CHECKING + +import pytest + +import focus_project as fp +from focus_project import minutes +from focus_project._paths import activity_path +from focus_project.tests.test_leetcode_source import ac_row, write +from focus_project.tests.test_minutes import at + +if TYPE_CHECKING: + from pathlib import Path + +DAY = date(2026, 11, 9) # after the cutover +PRE = date(2026, 11, 7) # before it + + +@pytest.fixture(autouse=True) +def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Nothing may fall back to the real home directory.""" + monkeypatch.setenv("HOME", str(tmp_path / "home")) + + +def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None: + fp.create_project( + pid, + name=pid, + start=date(2026, 11, 6), + end=date(2026, 11, 12), + replaces=["leetcode"], + daily_minutes=240, + unlock_minutes=30, + site_patterns=list(patterns), + ) + + +def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None: + """Append ``(source, kind, ts[, project])`` events (project default hr).""" + activity_path().parent.mkdir(parents=True, exist_ok=True) + with activity_path().open("a", encoding="utf-8") as handle: + for item in items: + source, kind, ts = item[:3] + pid = item[3] if len(item) == 4 else "hr" + row = {"ts": ts, "source": source, "kind": kind, "project": pid} + handle.write(json.dumps(row) + "\n") + + +def timer(day: date, *marks: tuple[int, int]) -> None: + """A segment: start at the first mark, check-ins, stop at the last.""" + kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"] + rows( + *( + ("timer", k, at(h, m, day=day)) + for k, (h, m) in zip(kinds, marks, strict=True) + ) + ) + + +def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None: + rows((source, "heartbeat", at(h, m, day=day), pid)) + + +@pytest.mark.parametrize("source", ["sql_drills", "leetcode"]) +def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None: + make() + timer(DAY, (9, 0), (9, 5), (9, 10)) + beat(source, 9, 5) + assert minutes.minutes_for(DAY) == 10 + + +def test_browser_and_other_project_beats_do_not_back() -> None: + make() # "other" needs no registry entry: rows are filtered by id + timer(DAY, (9, 0), (9, 5), (9, 10)) + beat("browser", 9, 4) + beat("tutor", 9, 4, pid="other") + assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only + assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10 + + +def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None: + make() + timer(DAY, (9, 0), (9, 5), (9, 10)) + write(tmp_path, ac_row(at(9, 4, day=DAY))) + assert minutes.minutes_for(DAY) == 11 # 8:59-9:10 + + +def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None: + make("hr", ("sqlzoo.net",)) + timer(DAY, (9, 0), (9, 5), (9, 10)) + write(tmp_path, ac_row(at(9, 4, day=DAY))) + assert minutes.minutes_for(DAY) == 0 + + +def test_span_across_the_cutover_midnight_follows_the_day() -> None: + make() + rows( + ("timer", "start", at(23, 58, day=date(2026, 11, 7))), + ("timer", "checkin", at(0, 3, day=date(2026, 11, 8))), + ("timer", "stop", at(0, 5, day=date(2026, 11, 8))), + ) + assert minutes.minutes_for(date(2026, 11, 7)) == 2 + assert minutes.minutes_for(date(2026, 11, 8)) == 0 + assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5 + + +def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None: + """A tutor beat chains the segment (0.8.0) and backs the span it ends. + + start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a + backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and + nothing bridges it, so it shows as unbacked. + """ + make() + rows( + ("timer", "start", at(9, 0, day=DAY)), + ("timer", "checkin", at(9, 8, day=DAY)), + ) + beat("tutor", 9, 16) + assert minutes.minutes_for(DAY) == 8 + assert minutes.unbacked_minutes_for(DAY) == 8