mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Real one-line docstrings stating what each test pins down. Files that would pass 250 lines moved whole classes into _part2 siblings (sick_panel, earned, poke_server, shutdown_reading, manual_workout_dialog). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
161 lines
6.5 KiB
Python
161 lines
6.5 KiB
Python
"""Tests for logging a manual workout for a future day from this PC."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
import json
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from screen_locker import _manual_schedule
|
|
from screen_locker._day import today_str
|
|
from screen_locker._manual_push import PushResult
|
|
from screen_locker._manual_schedule import budget_error, schedule_future_workout
|
|
from screen_locker._manual_workout import (
|
|
SPORT_OTHER,
|
|
ManualWorkoutDraft,
|
|
build_sync_payload,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
|
|
def _plus(days: int) -> str:
|
|
"""Return the local ISO date ``days`` from today."""
|
|
return (date.fromisoformat(today_str()) + timedelta(days=days)).isoformat()
|
|
|
|
|
|
def _draft(start: str = "18:00") -> ManualWorkoutDraft:
|
|
"""A valid 'other sport' draft."""
|
|
return ManualWorkoutDraft(
|
|
sport=SPORT_OTHER,
|
|
start_time=start,
|
|
end_time="19:30",
|
|
location_name="Padel Club",
|
|
transport_method="bike",
|
|
cost="40 PLN",
|
|
rpe=7,
|
|
went_well="Good rallies and fast footwork",
|
|
to_improve="Second serve needs more spin work",
|
|
overall_feeling="Tired but happy, legs felt strong",
|
|
activity_type_other="padel",
|
|
activity_details="Ninety minutes of padel doubles, baseline and net play",
|
|
)
|
|
|
|
|
|
@pytest.fixture(name="stubs")
|
|
def stubs_fixture(tmp_path: Path) -> Iterator[SimpleNamespace]:
|
|
"""Redirect the ledger; stub Firebase pull, the publisher and the identity."""
|
|
ledger = tmp_path / "pending.json"
|
|
pulled: list[tuple[str, dict]] = []
|
|
push = MagicMock(return_value=PushResult(pushed=True, record_count=1, reason="ok"))
|
|
with (
|
|
patch("screen_locker._manual_pending.MANUAL_PENDING_FILE", ledger),
|
|
patch.object(_manual_schedule, "pull_all_manual_records", lambda: list(pulled)),
|
|
patch.object(_manual_schedule, "push_pc_workouts", push),
|
|
patch.object(
|
|
_manual_schedule,
|
|
"device_identity",
|
|
lambda: SimpleNamespace(device_id="pc-test"),
|
|
),
|
|
):
|
|
yield SimpleNamespace(
|
|
ledger=ledger, pulled=pulled, push=push, log_file=tmp_path / "log.json"
|
|
)
|
|
|
|
|
|
class TestBudgetError:
|
|
"""Budget sees the Firebase pull AND this PC's ledger."""
|
|
|
|
def test_open_when_nothing_is_pending(self, stubs: SimpleNamespace) -> None:
|
|
"""With nothing pending the weekly budget has no error."""
|
|
assert budget_error(stubs.log_file, _plus(1)) is None
|
|
|
|
def test_phone_pending_entries_hold_a_slot(self, stubs: SimpleNamespace) -> None:
|
|
"""Entries pulled from the phone count against the budget."""
|
|
for offset in (1, 2):
|
|
payload = build_sync_payload(_draft(f"1{offset}:00"), _plus(offset))
|
|
stubs.pulled.append((f"manual:{_plus(offset)}T1{offset}:00", payload))
|
|
assert budget_error(stubs.log_file, _plus(3)) is not None
|
|
|
|
def test_excluding_an_id_frees_its_slot(self, stubs: SimpleNamespace) -> None:
|
|
"""Excluding a record id frees the slot that record held."""
|
|
ids = []
|
|
for offset in (1, 2):
|
|
rid = f"manual:{_plus(offset)}T1{offset}:00"
|
|
ids.append(rid)
|
|
stubs.pulled.append((rid, build_sync_payload(_draft(), _plus(offset))))
|
|
assert budget_error(stubs.log_file, _plus(3)) is not None
|
|
assert budget_error(stubs.log_file, _plus(3), exclude_id=ids[0]) is None
|
|
|
|
|
|
class TestScheduleFutureWorkout:
|
|
"""Hold a validated draft for a later day and publish it."""
|
|
|
|
def test_schedules_stores_and_publishes(self, stubs: SimpleNamespace) -> None:
|
|
"""A valid future draft is stored in the ledger and pushed once without touching the log."""
|
|
day = _plus(1)
|
|
|
|
result = schedule_future_workout(stubs.log_file, _draft(), day)
|
|
|
|
assert result.scheduled is True
|
|
assert result.record_id == f"manual:{day}T18:00"
|
|
assert "counts automatically" in result.reason
|
|
assert list(json.loads(stubs.ledger.read_text())) == [result.record_id]
|
|
stubs.push.assert_called_once_with(stubs.log_file)
|
|
assert not stubs.log_file.exists()
|
|
|
|
def test_an_unpublished_entry_is_still_scheduled_and_says_so(
|
|
self, stubs: SimpleNamespace
|
|
) -> None:
|
|
"""A failed publish still schedules the entry and reports it is unpublished."""
|
|
stubs.push.return_value = PushResult(
|
|
pushed=False, record_count=1, reason="Firebase down"
|
|
)
|
|
result = schedule_future_workout(stubs.log_file, _draft(), _plus(2))
|
|
assert result.scheduled is True
|
|
assert "not yet published: Firebase down" in result.reason
|
|
|
|
def test_today_is_not_a_future_date(self, stubs: SimpleNamespace) -> None:
|
|
"""Today is refused as a schedule date and nothing is pushed."""
|
|
result = schedule_future_workout(stubs.log_file, _draft(), _plus(0))
|
|
assert result.scheduled is False
|
|
assert "is today" in result.reason
|
|
stubs.push.assert_not_called()
|
|
|
|
@pytest.mark.parametrize("ahead", [-1, 8])
|
|
def test_a_date_outside_the_window_is_refused(
|
|
self, stubs: SimpleNamespace, ahead: int
|
|
) -> None:
|
|
"""Dates in the past or beyond the window are refused with nothing stored."""
|
|
result = schedule_future_workout(stubs.log_file, _draft(), _plus(ahead))
|
|
assert result.scheduled is False
|
|
assert not stubs.ledger.exists()
|
|
|
|
def test_an_exhausted_budget_is_refused(self, stubs: SimpleNamespace) -> None:
|
|
"""Once two workouts are scheduled a third is refused for budget."""
|
|
assert schedule_future_workout(
|
|
stubs.log_file, _draft("10:00"), _plus(1)
|
|
).scheduled
|
|
assert schedule_future_workout(
|
|
stubs.log_file, _draft("11:00"), _plus(2)
|
|
).scheduled
|
|
refused = schedule_future_workout(stubs.log_file, _draft("12:00"), _plus(3))
|
|
assert refused.scheduled is False
|
|
assert "budget" in refused.reason.lower()
|
|
assert len(json.loads(stubs.ledger.read_text())) == 2
|
|
|
|
def test_the_same_entry_twice_is_refused(self, stubs: SimpleNamespace) -> None:
|
|
"""Scheduling the same entry twice is refused and pushed only once."""
|
|
day = _plus(1)
|
|
assert schedule_future_workout(stubs.log_file, _draft(), day).scheduled
|
|
again = schedule_future_workout(stubs.log_file, _draft(), day)
|
|
assert again.scheduled is False
|
|
assert "already scheduled" in again.reason
|
|
stubs.push.assert_called_once()
|