mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:27:16 +01:00
focus_project: pin backing edge cases and pay
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
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (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
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (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
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 <noreply@anthropic.com>
This commit is contained in:
parent
afcc1a47c7
commit
17e300af0a
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
@ -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
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user