mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-14 16:30:55 +01:00
The phone form was the only way to log a manual workout, so logging one depended on the phone's foreground. Another app taking focus mid-entry could interleave a field (seen: "Played 6 games 1 did nPlayed 6 games...") or drop one, which is a bad property for a record that spends budget. `--log-manual-workout` takes every field as a flag and needs no UI, no phone and no app. It is emphatically NOT a way to log a workout without evidence: the draft goes through the same `validate_manual_workout`, so a missing or too-short field refuses the whole run, writes nothing and exits non-zero with the concrete reason. Sport-specific rules survive too -- table tennis still demands its match counts, racket and balls. Routed through `ingest_manual_records` rather than writing `log.json` directly, so validation, the rolling 2/week budget, HMAC signing and workout_id dedup are the shared implementation with the sync path instead of a second copy that can drift. That is the difference from the deleted `log_manual_workout.py`, which bypassed the checks: here the budget refuses a third entry in a week exactly as the phone does, and re-running an identical command is a no-op rather than a duplicate. "Today" is UTC, matching how the budget windows compute it -- a local date near midnight would file a workout in a different window than it is counted in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
177 lines
5.5 KiB
Python
177 lines
5.5 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
|
|
|
|
import pytest
|
|
|
|
from screen_locker._manual_cli import run_manual_log
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_VALID = (
|
|
"--sport",
|
|
"other",
|
|
"--activity",
|
|
"padel",
|
|
"--date",
|
|
"2026-08-27",
|
|
"--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."""
|
|
log_file = tmp_path / "log.json"
|
|
|
|
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"] == "manual:2026-08-27T17: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", "2026-08-27"}]
|
|
|
|
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"
|