mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 12:07:44 +01:00
A manual workout may be dated today..today+7 days (no past dates). A future entry is held out of log.json and earns nothing until its date; the first sync/lock/reset pass on that day ingests and credits it like a workout logged that day. - ingest_manual_records defers records dated after the local day and counts the budget (2/7d, 10/30d) against the workout's own date over logged credits plus pending future records (a pending record never rejects itself) - PC-logged future entries (Tk dialog, --log-manual-workout --date) go to a durable pending ledger re-published on every push (a one-shot publish to devices/pc is overwritten by the next push); tombstones win, entries leave the ledger once logged or deleted - one shared date helper (validate_workout_date) used by the dialog and CLI; the form's date field rides in the sport row to keep the lock screen fitting - poke start_the_day also ingests due local entries after the daily reset - PC admission rule pinned to the phone's by the shared budget fixture (planned.expected_by_date) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mmTBiMpvyfLuEAXc6nDYT
168 lines
6.4 KiB
Python
168 lines
6.4 KiB
Python
"""Cross-language fixture test for manual-workout budget counting.
|
|
|
|
The Dart app mirrors this accounting in ``lib/models/manual_workout_budget.dart``
|
|
and ``lib/services/workout_sync_service_read.dart``. Each language's own
|
|
round-trip test passed happily while the two sides disagreed -- on 2026-08-27
|
|
the phone read ``2/2w 4/10m (exhausted)`` for the very records this side read
|
|
as ``1/2w 2/10m`` -- so the only thing that catches the drift is a shared
|
|
literal both read: ``fixtures/manual_budget_merge.json``. The Dart twin of
|
|
this file is ``test/models/manual_budget_merge_fixture_test.dart``; they must
|
|
stay in step.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from screen_locker._log_mixin import write_signed_entry
|
|
from screen_locker._manual_future import budget_error_for_date, budget_usage_for_date
|
|
from screen_locker._manual_workout import (
|
|
MANUAL_WORKOUT_TYPE,
|
|
count_in_window,
|
|
is_budget_exhausted,
|
|
)
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "manual_budget_merge.json"
|
|
|
|
|
|
def _fixture() -> dict[str, Any]:
|
|
"""Load the shared cross-language budget fixture."""
|
|
loaded: dict[str, Any] = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
return loaded
|
|
|
|
|
|
def _live_payloads(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Drop tombstoned records, as the sync reader does across the union."""
|
|
return [r["payload"] for r in records if not r["deleted"]]
|
|
|
|
|
|
def _log_from(payloads: list[dict[str, Any]], log_file: Path) -> Path:
|
|
"""Write the ingestable payloads into a real signed log file.
|
|
|
|
Stubs without ``start_time`` are skipped exactly as ``_manual_sync``
|
|
refuses them ("no workout data to ingest"), so the fixture exercises the
|
|
same admission rule the live ingest applies.
|
|
"""
|
|
for payload in payloads:
|
|
if not isinstance(payload.get("start_time"), str):
|
|
continue
|
|
workout_data = {k: v for k, v in payload.items() if k != "kind"}
|
|
workout_data["type"] = MANUAL_WORKOUT_TYPE
|
|
workout_data["workout_id"] = f"manual:{payload['date']}T{payload['start_time']}"
|
|
write_signed_entry(log_file, str(payload["date"]), workout_data)
|
|
return log_file
|
|
|
|
|
|
def test_budget_matches_the_phone_for_the_2026_08_27_divergence(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The window counts must equal the numbers the Dart twin asserts."""
|
|
fixture = _fixture()
|
|
expected = fixture["expected"]
|
|
today = str(fixture["today"])
|
|
log_file = _log_from(_live_payloads(fixture["records"]), tmp_path / "log.json")
|
|
|
|
assert count_in_window(log_file, 7, today=today) == expected["week"]
|
|
assert count_in_window(log_file, 30, today=today) == expected["month"]
|
|
assert is_budget_exhausted(log_file, today=today) is expected["exhausted"]
|
|
|
|
|
|
def test_a_tombstoned_duplicate_frees_its_weekly_slot(tmp_path: Path) -> None:
|
|
"""Resurrecting the deleted duplicate must exhaust the week.
|
|
|
|
This is the pre-fix behaviour the phone exhibited; asserting it proves the
|
|
tombstone is load-bearing rather than incidentally outside the window.
|
|
"""
|
|
fixture = _fixture()
|
|
today = str(fixture["today"])
|
|
resurrected = [r["payload"] for r in fixture["records"]]
|
|
log_file = _log_from(resurrected, tmp_path / "log.json")
|
|
|
|
assert count_in_window(log_file, 7, today=today) == 2
|
|
assert is_budget_exhausted(log_file, today=today) is True
|
|
|
|
|
|
def test_an_evidence_less_stub_never_consumes_budget(tmp_path: Path) -> None:
|
|
"""The `{type, kind, date}` stub must cost nothing in either window."""
|
|
fixture = _fixture()
|
|
today = str(fixture["today"])
|
|
stub = next(
|
|
r["payload"] for r in fixture["records"] if "start_time" not in r["payload"]
|
|
)
|
|
log_file = _log_from([stub], tmp_path / "log.json")
|
|
|
|
assert count_in_window(log_file, 7, today=today) == 0
|
|
assert count_in_window(log_file, 30, today=today) == 0
|
|
|
|
|
|
def _planned_cases() -> list[tuple[str, dict[str, Any]]]:
|
|
"""Every ``planned.expected_by_date`` entry as ``(date, expectation)``."""
|
|
return sorted(_fixture()["planned"]["expected_by_date"].items())
|
|
|
|
|
|
def _planned_live_payloads() -> list[dict[str, Any]]:
|
|
"""The planned scenario: top-level records plus ``extra_records``, live only."""
|
|
fixture = _fixture()
|
|
records = [*fixture["records"], *fixture["planned"]["extra_records"]]
|
|
return _live_payloads(records)
|
|
|
|
|
|
@pytest.mark.parametrize(("day", "expected"), _planned_cases())
|
|
def test_planned_budget_by_workout_date_matches_the_phone(
|
|
tmp_path: Path, day: str, expected: dict[str, Any]
|
|
) -> None:
|
|
"""The per-date admission rule must equal the phone's, entry by entry.
|
|
|
|
The planned entries sit in the log here; the PC holds them out of the log
|
|
until their day, so the same scenario is also run with them as pending
|
|
dates (next test) and both must agree.
|
|
"""
|
|
log_file = _log_from(_planned_live_payloads(), tmp_path / "log.json")
|
|
|
|
usage = budget_usage_for_date(log_file, day)
|
|
|
|
assert usage is not None
|
|
assert usage.week == expected["week"]
|
|
assert usage.month == expected["month"]
|
|
assert usage.exhausted is expected["exhausted"]
|
|
assert (budget_error_for_date(log_file, day) is not None) is expected["exhausted"]
|
|
|
|
|
|
@pytest.mark.parametrize(("day", "expected"), _planned_cases())
|
|
def test_planned_entries_held_as_pending_count_the_same(
|
|
tmp_path: Path, day: str, expected: dict[str, Any]
|
|
) -> None:
|
|
"""Pending future entries (not yet in the log) hold the same slots."""
|
|
fixture = _fixture()
|
|
today = str(fixture["today"])
|
|
live = _planned_live_payloads()
|
|
logged = [p for p in live if str(p["date"]) <= today]
|
|
pending = [str(p["date"]) for p in live if str(p["date"]) > today]
|
|
log_file = _log_from(logged, tmp_path / "log.json")
|
|
|
|
usage = budget_usage_for_date(log_file, day, pending)
|
|
|
|
assert usage is not None
|
|
assert (usage.week, usage.month) == (expected["week"], expected["month"])
|
|
assert usage.exhausted is expected["exhausted"]
|
|
|
|
|
|
def test_a_cancelled_plan_frees_its_slot(tmp_path: Path) -> None:
|
|
"""Resurrecting the deleted 08-30 plan must exhaust the week around 08-28."""
|
|
fixture = _fixture()
|
|
everything = [
|
|
r["payload"]
|
|
for r in [*fixture["records"], *fixture["planned"]["extra_records"]]
|
|
]
|
|
log_file = _log_from(everything, tmp_path / "log.json")
|
|
|
|
usage = budget_usage_for_date(log_file, "2026-08-28")
|
|
|
|
assert usage is not None
|
|
assert usage.week == 2
|
|
assert usage.exhausted is True
|