screen-locker/screen_locker/tests/test_manual_budget_merge_fixture.py
Krzysztof kuhy Rudnicki e12b840f8c manual workout: log for future dates (credited on the day)
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
2026-10-16 15:34:15 +02:00

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