mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Synced manual workouts never earned shutdown/debt credit (only weekly count), which silently missed pushing shutdown time for a same-day workout logged on the phone. Manual workouts now go through the same credit logic as any other workout type, count individually toward the weekly total and rate budget (no more once-per-day collapse), and stack same-day shutdown bonuses like verified workouts. Also bumps the weekly minimum from 4 to 5 and doubles the monthly manual-workout budget to 10. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
400 lines
14 KiB
Python
400 lines
14 KiB
Python
"""Tests for the manual-workout pure-logic module."""
|
|
# pylint: disable=protected-access
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
from datetime import datetime, timedelta, timezone
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from screen_locker._constants import (
|
|
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
|
|
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
|
|
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
|
|
MANUAL_WORKOUT_MIN_DURATION_MINUTES,
|
|
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
|
)
|
|
from screen_locker._manual_workout import (
|
|
SPORT_OTHER,
|
|
SPORT_TABLE_TENNIS,
|
|
ManualWorkoutDraft,
|
|
budget_summary,
|
|
build_entry,
|
|
build_sync_payload,
|
|
count_in_window,
|
|
is_budget_exhausted,
|
|
manual_sync_record_id,
|
|
validate_manual_workout,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_TODAY = "2026-07-05"
|
|
|
|
|
|
def _write_logs(log_file: Path, entries: dict[str, str]) -> None:
|
|
"""Write a workout_log.json with one entry per {date: type} pair."""
|
|
logs = {
|
|
date: {"timestamp": f"{date}T12:00:00+00:00", "workout_data": {"type": wtype}}
|
|
for date, wtype in entries.items()
|
|
}
|
|
log_file.write_text(json.dumps(logs))
|
|
|
|
|
|
class TestCountInWindow:
|
|
"""Tests for count_in_window."""
|
|
|
|
def test_counts_only_manual_workout_type(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
_write_logs(
|
|
log_file,
|
|
{
|
|
"2026-07-04": "manual_workout",
|
|
"2026-07-03": "phone_verified",
|
|
"2026-07-02": "manual_workout",
|
|
},
|
|
)
|
|
assert count_in_window(log_file, 7, today=_TODAY) == 2
|
|
|
|
def test_respects_window_cutoff(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
_write_logs(
|
|
log_file,
|
|
{
|
|
"2026-07-04": "manual_workout", # 1 day ago: in 7d
|
|
"2026-06-28": "manual_workout", # 7 days ago: NOT in 7d (exclusive)
|
|
"2026-06-20": "manual_workout", # 15 days ago: in 30d, not 7d
|
|
},
|
|
)
|
|
assert count_in_window(log_file, 7, today=_TODAY) == 1
|
|
assert count_in_window(log_file, 30, today=_TODAY) == 3
|
|
|
|
def test_missing_file_returns_zero(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "does_not_exist.json"
|
|
assert count_in_window(log_file, 7, today=_TODAY) == 0
|
|
|
|
def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
log_file.write_text("not json")
|
|
assert count_in_window(log_file, 7, today=_TODAY) == 0
|
|
|
|
def test_skips_invalid_date_keys(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
log_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"not-a-date": {"workout_data": {"type": "manual_workout"}},
|
|
"2026-07-04": {"workout_data": {"type": "manual_workout"}},
|
|
}
|
|
)
|
|
)
|
|
assert count_in_window(log_file, 7, today=_TODAY) == 1
|
|
|
|
def test_returns_zero_when_today_invalid(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
_write_logs(log_file, {"2026-07-04": "manual_workout"})
|
|
assert count_in_window(log_file, 7, today="bogus") == 0
|
|
|
|
def test_uses_today_default_when_none(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
assert count_in_window(log_file, 7) == 0
|
|
|
|
def test_multiple_same_day_entries_each_count(self, tmp_path: Path) -> None:
|
|
"""Per-entry counting: same-day manual workouts no longer collapse to
|
|
one slot — each consumes its own, matching the new weekly-count
|
|
parity with verified workouts."""
|
|
log_file = tmp_path / "workout_log.json"
|
|
log_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"2026-07-04": [
|
|
{"workout_data": {"type": "manual_workout"}},
|
|
{"workout_data": {"type": "manual_workout"}},
|
|
],
|
|
}
|
|
)
|
|
)
|
|
assert count_in_window(log_file, 7, today=_TODAY) == 2
|
|
|
|
|
|
class TestIsBudgetExhausted:
|
|
"""Tests for is_budget_exhausted."""
|
|
|
|
def test_false_when_under_budget(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
assert is_budget_exhausted(log_file, today=_TODAY) is False
|
|
|
|
def test_true_when_weekly_exhausted(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
entries = {
|
|
f"2026-07-0{4 - i}": "manual_workout"
|
|
for i in range(MANUAL_WORKOUT_BUDGET_PER_7_DAYS)
|
|
}
|
|
_write_logs(log_file, entries)
|
|
assert is_budget_exhausted(log_file, today=_TODAY) is True
|
|
|
|
def test_true_when_monthly_exhausted(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
# MANUAL_WORKOUT_BUDGET_PER_30_DAYS distinct dates, all strictly after
|
|
# the 30d cutoff (2026-06-05) but at/before the 7d cutoff (2026-06-28),
|
|
# so only the 30d window sees them.
|
|
today_dt = datetime.strptime(_TODAY, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
dates = [
|
|
(today_dt - timedelta(days=8 + i)).strftime("%Y-%m-%d")
|
|
for i in range(MANUAL_WORKOUT_BUDGET_PER_30_DAYS)
|
|
]
|
|
entries = dict.fromkeys(dates, "manual_workout")
|
|
_write_logs(log_file, entries)
|
|
assert count_in_window(log_file, 7, today=_TODAY) == 0
|
|
assert is_budget_exhausted(log_file, today=_TODAY) is True
|
|
|
|
|
|
class TestBudgetSummary:
|
|
"""Tests for budget_summary."""
|
|
|
|
def test_renders_both_windows(self, tmp_path: Path) -> None:
|
|
log_file = tmp_path / "workout_log.json"
|
|
_write_logs(log_file, {"2026-07-04": "manual_workout"})
|
|
summary = budget_summary(log_file, today=_TODAY)
|
|
assert "Manual:" in summary
|
|
assert "1/" in summary
|
|
|
|
|
|
_TT_DEFAULT = ManualWorkoutDraft(
|
|
sport=SPORT_TABLE_TENNIS,
|
|
start_time="12:00",
|
|
end_time="14:00",
|
|
location_name="Osrodek Solec",
|
|
transport_method="bike",
|
|
cost="60 PLN",
|
|
rpe=6,
|
|
went_well="x" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
|
to_improve="y" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
|
overall_feeling="z" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
|
matches_won=1,
|
|
matches_lost=4,
|
|
sets_won=2,
|
|
sets_lost=9,
|
|
racket="pro spin",
|
|
balls="nittaku",
|
|
)
|
|
|
|
_OTHER_DEFAULT = ManualWorkoutDraft(
|
|
sport=SPORT_OTHER,
|
|
start_time="09:00",
|
|
end_time="10:00",
|
|
location_name="Squash club",
|
|
transport_method="car",
|
|
cost="30 PLN",
|
|
rpe=5,
|
|
went_well="x" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
|
to_improve="y" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
|
overall_feeling="z" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
|
activity_type_other="squash",
|
|
activity_details="q" * MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
|
|
)
|
|
|
|
|
|
def _tt_draft(**overrides: object) -> ManualWorkoutDraft:
|
|
"""Build a valid table-tennis draft, with overrides applied."""
|
|
return dataclasses.replace(_TT_DEFAULT, **overrides)
|
|
|
|
|
|
def _other_draft(**overrides: object) -> ManualWorkoutDraft:
|
|
"""Build a valid "other sport" draft, with overrides applied."""
|
|
return dataclasses.replace(_OTHER_DEFAULT, **overrides)
|
|
|
|
|
|
class TestValidateManualWorkout:
|
|
"""Tests for validate_manual_workout."""
|
|
|
|
def test_valid_table_tennis_returns_none(self) -> None:
|
|
assert validate_manual_workout(_tt_draft()) is None
|
|
|
|
def test_valid_other_returns_none(self) -> None:
|
|
assert validate_manual_workout(_other_draft()) is None
|
|
|
|
def test_rejects_unknown_sport(self) -> None:
|
|
assert validate_manual_workout(_tt_draft(sport="badminton")) is not None
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
["start_time", "end_time", "location_name", "transport_method", "cost"],
|
|
)
|
|
def test_rejects_blank_required_field(self, field: str) -> None:
|
|
assert validate_manual_workout(_tt_draft(**{field: " "})) is not None
|
|
|
|
def test_rejects_bad_time_format(self) -> None:
|
|
assert validate_manual_workout(_tt_draft(start_time="noon")) is not None
|
|
|
|
def test_rejects_end_before_start(self) -> None:
|
|
assert (
|
|
validate_manual_workout(_tt_draft(start_time="14:00", end_time="12:00"))
|
|
is not None
|
|
)
|
|
|
|
def test_rejects_too_short_duration(self) -> None:
|
|
draft = _tt_draft(start_time="12:00", end_time="12:05")
|
|
assert MANUAL_WORKOUT_MIN_DURATION_MINUTES > 5
|
|
assert validate_manual_workout(draft) is not None
|
|
|
|
@pytest.mark.parametrize("rpe", [0, 11, -1])
|
|
def test_rejects_rpe_out_of_range(self, rpe: int) -> None:
|
|
assert validate_manual_workout(_tt_draft(rpe=rpe)) is not None
|
|
|
|
@pytest.mark.parametrize(
|
|
"field", ["matches_won", "matches_lost", "sets_won", "sets_lost"]
|
|
)
|
|
def test_rejects_negative_table_tennis_counts(self, field: str) -> None:
|
|
assert validate_manual_workout(_tt_draft(**{field: -1})) is not None
|
|
|
|
def test_rejects_zero_matches_played(self) -> None:
|
|
draft = _tt_draft(matches_won=0, matches_lost=0)
|
|
assert validate_manual_workout(draft) is not None
|
|
|
|
def test_rejects_blank_racket(self) -> None:
|
|
assert validate_manual_workout(_tt_draft(racket=" ")) is not None
|
|
|
|
def test_rejects_blank_balls(self) -> None:
|
|
assert validate_manual_workout(_tt_draft(balls="")) is not None
|
|
|
|
def test_rejects_blank_activity_type_other(self) -> None:
|
|
assert validate_manual_workout(_other_draft(activity_type_other="")) is not None
|
|
|
|
def test_rejects_short_activity_details(self) -> None:
|
|
assert (
|
|
validate_manual_workout(_other_draft(activity_details="too short"))
|
|
is not None
|
|
)
|
|
|
|
@pytest.mark.parametrize("field", ["went_well", "to_improve", "overall_feeling"])
|
|
def test_rejects_short_reflection_field(self, field: str) -> None:
|
|
assert validate_manual_workout(_tt_draft(**{field: "short"})) is not None
|
|
|
|
|
|
class TestBuildEntry:
|
|
"""Tests for build_entry."""
|
|
|
|
def test_table_tennis_entry_shape(self) -> None:
|
|
entry = build_entry(_tt_draft())
|
|
assert entry["type"] == "manual_workout"
|
|
assert entry["sport"] == "table_tennis"
|
|
assert entry["activity_type"] == "table tennis"
|
|
assert entry["source"] == "table tennis at Osrodek Solec"
|
|
assert entry["duration_minutes"] == "120.0"
|
|
assert entry["matches_won"] == 1
|
|
assert entry["matches_lost"] == 4
|
|
assert entry["sets_won"] == 2
|
|
assert entry["sets_lost"] == 9
|
|
assert entry["racket"] == "pro spin"
|
|
assert entry["balls"] == "nittaku"
|
|
assert "activity_details" not in entry
|
|
assert "equipment" not in entry
|
|
|
|
def test_other_sport_entry_shape(self) -> None:
|
|
entry = build_entry(_other_draft())
|
|
assert entry["type"] == "manual_workout"
|
|
assert entry["sport"] == "other"
|
|
assert entry["activity_type"] == "squash"
|
|
assert entry["source"] == "squash at Squash club"
|
|
assert entry["activity_details"] == "q" * MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS
|
|
assert "matches_won" not in entry
|
|
assert "racket" not in entry
|
|
|
|
def test_duration_minutes_empty_when_unparsable(self) -> None:
|
|
entry = build_entry(_tt_draft(start_time="bogus"))
|
|
assert entry["duration_minutes"] == ""
|
|
|
|
def test_strips_whitespace_fields(self) -> None:
|
|
entry = build_entry(_tt_draft(location_name=" Solec ", racket=" spin "))
|
|
assert entry["location_name"] == "Solec"
|
|
assert entry["racket"] == "spin"
|
|
|
|
def test_pain_or_injury_defaults_to_none(self) -> None:
|
|
entry = build_entry(_tt_draft())
|
|
assert entry["pain_or_injury"] == "none"
|
|
|
|
|
|
# The canonical manual-workout sync payload. This literal is the cross-language
|
|
# wire contract shared with the Flutter workout_app: an IDENTICAL literal lives
|
|
# in the Dart test suite (manual_workout model test). Neither side's own
|
|
# round-trip test can catch a key-name/format drift from the other (e.g.
|
|
# went_well vs wentWell, duration_minutes formatting) — only this shared literal
|
|
# on both sides does. If you change a field here, change it there too.
|
|
_WIRE_DATE = "2026-07-13"
|
|
_WIRE_DRAFT = ManualWorkoutDraft(
|
|
sport=SPORT_TABLE_TENNIS,
|
|
start_time="18:00",
|
|
end_time="19:30",
|
|
location_name="Solec",
|
|
transport_method="bike",
|
|
cost="40 PLN",
|
|
rpe=6,
|
|
went_well="Served consistently and moved feet well",
|
|
to_improve="Backhand topspin needs more consistency",
|
|
overall_feeling="Felt strong focused and in good rhythm",
|
|
reservation_phone="600100200",
|
|
techniques_practiced="topspin serve",
|
|
warm_up_minutes="10",
|
|
pain_or_injury="none",
|
|
matches_won=3,
|
|
matches_lost=1,
|
|
sets_won=7,
|
|
sets_lost=4,
|
|
racket="Butterfly",
|
|
balls="Nittaku 3-star",
|
|
)
|
|
_WIRE_PAYLOAD: dict[str, object] = {
|
|
"type": "manual_workout",
|
|
"source": "table tennis at Solec",
|
|
"sport": "table_tennis",
|
|
"activity_type": "table tennis",
|
|
"start_time": "18:00",
|
|
"end_time": "19:30",
|
|
"duration_minutes": "90.0",
|
|
"location_name": "Solec",
|
|
"transport_method": "bike",
|
|
"cost": "40 PLN",
|
|
"reservation_phone": "600100200",
|
|
"rpe": 6,
|
|
"techniques_practiced": "topspin serve",
|
|
"warm_up_minutes": "10",
|
|
"pain_or_injury": "none",
|
|
"went_well": "Served consistently and moved feet well",
|
|
"to_improve": "Backhand topspin needs more consistency",
|
|
"overall_feeling": "Felt strong focused and in good rhythm",
|
|
"matches_won": 3,
|
|
"matches_lost": 1,
|
|
"sets_won": 7,
|
|
"sets_lost": 4,
|
|
"racket": "Butterfly",
|
|
"balls": "Nittaku 3-star",
|
|
"kind": "manual_workout",
|
|
"date": "2026-07-13",
|
|
}
|
|
|
|
|
|
class TestSyncWireFormat:
|
|
"""Cross-language wire-format contract for the manual-workout sync payload."""
|
|
|
|
def test_build_sync_payload_matches_fixture(self) -> None:
|
|
assert build_sync_payload(_WIRE_DRAFT, _WIRE_DATE) == _WIRE_PAYLOAD
|
|
|
|
def test_sync_payload_is_json_serializable(self) -> None:
|
|
# The Dart side round-trips the same literal through JSON; guard that our
|
|
# payload contains only JSON-native types (no stray dataclass/objects).
|
|
restored = json.loads(json.dumps(build_sync_payload(_WIRE_DRAFT, _WIRE_DATE)))
|
|
assert restored == _WIRE_PAYLOAD
|
|
|
|
def test_record_id_is_stable_and_prefixed(self) -> None:
|
|
assert manual_sync_record_id(_WIRE_DATE, "18:00") == "manual:2026-07-13T18:00"
|
|
|
|
def test_kind_discriminator_present_and_typed(self) -> None:
|
|
payload = build_sync_payload(_WIRE_DRAFT, _WIRE_DATE)
|
|
# Absent-kind means a StrongLifts session; a manual must be tagged.
|
|
assert payload["kind"] == "manual_workout"
|