mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +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
195 lines
6.3 KiB
Python
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"
|