screen-locker/screen_locker/tests/test_manual_cli.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

195 lines
6.3 KiB
Python

"""Tests for headless manual-workout logging (``--log-manual-workout``).
The point of these is the REFUSALS. A headless entry point is only safe if it
cannot log a workout the phone form would have rejected, so each way of being
invalid is asserted to write nothing and exit non-zero.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from screen_locker._day import today_str
from screen_locker._manual_cli import run_manual_log
if TYPE_CHECKING:
from pathlib import Path
# The CLI refuses past dates, so the workout is dated today (a fixed date
# would silently turn every test into a "past date" refusal one day later).
_TODAY = today_str()
_VALID = (
"--sport",
"other",
"--activity",
"padel",
"--date",
_TODAY,
"--start",
"17:30",
"--end",
"19:00",
"--location",
"Padel PGE Narodowy",
"--transport",
"By bus, 2 przesiadki",
"--cost",
"60 PLN",
"--rpe",
"4",
"--details",
"Played 6 games 1 did not finish won 3 lost 2",
"--went-well",
"my serves got much better, with good speed and accuracy",
"--to-improve",
"first game was sluggish, I was tired from work",
"--feeling",
"after the tiredness went away the game went very well indeed",
)
def _args(**overrides: str) -> list[str]:
"""Return the valid arg list with ``--flag`` values replaced."""
args = list(_VALID)
for flag, value in overrides.items():
idx = args.index(f"--{flag.replace('_', '-')}")
args[idx + 1] = value
return args
def _entries(log_file: Path) -> list[dict]:
"""Return every logged entry, or [] when nothing was written."""
if not log_file.exists():
return []
return [e for day in json.loads(log_file.read_text()).values() for e in day]
def test_logs_a_valid_workout(tmp_path: Path) -> None:
"""A complete draft is signed, filed under its date and reported.
The signature is stubbed rather than taken from the real key: it lives at
``/etc/workout-locker/hmac.key``, which exists on the machine that runs
the locker and not on a CI runner. Without this the assertion below was a
``KeyError`` on CI and a pass locally -- the test was measuring the host,
not the code. Saving an entry UNSIGNED when no key is available is
deliberate behaviour, covered separately by
``test_manual_sync.test_writes_unsigned_entry_when_hmac_key_unavailable``.
"""
log_file = tmp_path / "log.json"
with patch(
"screen_locker._log_mixin.compute_entry_hmac", return_value="test-signature"
):
assert run_manual_log(log_file, _args()) == 0
(entry,) = _entries(log_file)
data = entry["workout_data"]
assert data["type"] == "manual_workout"
assert data["activity_type"] == "padel"
assert data["duration_minutes"] == "90.0"
assert data["rpe"] == 4
assert entry["workout_id"] == f"manual:{_TODAY}T17:30"
assert entry["hmac"]
def test_unsupplied_optional_matches_the_form_default(tmp_path: Path) -> None:
"""``pain_or_injury`` defaults to "none" -- an answer, not a blank.
An empty string would read as "never asked", which is a different claim
from the one the form records.
"""
log_file = tmp_path / "log.json"
run_manual_log(log_file, _args())
(entry,) = _entries(log_file)
assert entry["workout_data"]["pain_or_injury"] == "none"
@pytest.mark.parametrize(
("field", "value", "why"),
[
("details", "too short", "under the 40-char activity-details bar"),
("went_well", "brief", "under the 20-char reflection bar"),
("to_improve", "brief", "under the 20-char reflection bar"),
("feeling", "brief", "under the 20-char reflection bar"),
("location", "", "required field left blank"),
("transport", "", "required field left blank"),
("cost", "", "required field left blank"),
("rpe", "11", "outside the 1-10 RPE range"),
("end", "17:35", "under the minimum workout duration"),
],
)
def test_refuses_and_writes_nothing(
tmp_path: Path, field: str, value: str, why: str
) -> None:
"""Every way of being invalid refuses the whole run.
Asserted per-field rather than once, because a headless path that logged
even one of these would be the deleted ``log_manual_workout.py`` again.
"""
log_file = tmp_path / "log.json"
assert run_manual_log(log_file, _args(**{field: value})) == 1, why
assert _entries(log_file) == []
def test_rerunning_the_same_workout_logs_it_once(tmp_path: Path) -> None:
"""The record id is derived from (date, start), so a repeat is a no-op."""
log_file = tmp_path / "log.json"
assert run_manual_log(log_file, _args()) == 0
assert run_manual_log(log_file, _args()) == 1
assert len(_entries(log_file)) == 1
def test_the_rolling_budget_still_caps_headless_entries(tmp_path: Path) -> None:
"""The third workout in a week is refused, exactly as on the phone."""
log_file = tmp_path / "log.json"
assert run_manual_log(log_file, _args(start="07:00", end="08:30")) == 0
assert run_manual_log(log_file, _args(start="10:00", end="11:30")) == 0
assert run_manual_log(log_file, _args(start="17:30", end="19:00")) == 1
assert len(_entries(log_file)) == 2
def test_date_defaults_to_today(tmp_path: Path) -> None:
"""Omitting --date files the workout under today rather than failing."""
log_file = tmp_path / "log.json"
args = [a for a in _args() if a not in {"--date", _TODAY}]
assert run_manual_log(log_file, args) == 0
assert _entries(log_file)
def test_table_tennis_needs_no_activity_name(tmp_path: Path) -> None:
"""``--activity`` is only meaningful for the open-ended "other" sport.
Table tennis names itself, but it does demand its own evidence (a match
count), so the sport-specific rules survive the headless path too.
"""
log_file = tmp_path / "log.json"
args = _args(sport="table_tennis")
args[args.index("--activity") + 1] = ""
args += [
"--matches-won",
"3",
"--matches-lost",
"1",
"--sets-won",
"7",
"--sets-lost",
"4",
"--racket",
"Butterfly",
"--balls",
"Nittaku",
]
assert run_manual_log(log_file, args) == 0
assert _entries(log_file)[0]["workout_data"]["activity_type"] == "table tennis"