mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
Let the PC see phone workouts again, and stop losing progression on reinstall
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
A real StrongLifts session on 2026-08-10 never reached this machine, so it had to be logged as a manual workout -- spending a manual budget slot on a workout that was genuinely verified. The session was in Firebase the whole time; four separate defects each hid it. - pull_synced_workout() read a hardcoded devices/phone/log.json. Sinceffc6c72the phone pushes under a per-install uuid, so that directory has been frozen since 2026-07-17 and the PC kept returning a stale session from 07-27 as if it were current. Both readers now walk EVERY device directory; reinstalls keep minting new ones, so no single path can be right for long. - A session is identified by SHAPE (it has `exercises`), not by a `kind` field the phone never writes. That filter is why the one reader that did walk every directory discarded real sessions, and it also kept PC-origin runnerup/phone_verified records from being handed to a caller expecting a session. - FirebaseSyncError is a SIBLING of GitHubSyncError, not a subclass, so `except GitHubSyncError` never caught it and a DNS failure took the whole locker down (status=1/FAILURE, then a restart loop). Catch the common parent; a pull failure degrades to a warning. - A GitHub token is no longer required to sync. Firebase has been primary sinceeb4ff01, so a Firebase-only machine was reporting "sync is OFF" while syncing fine. Sync also only ran once per locker start, so a workout finished after login was invisible until the next login. workout-sync.timer runs the new headless --sync-only every 15 minutes; remove_systemd.sh now removes every unit the installer creates, not just two of them. workout_app: the 2026-08-05 reinstall wiped progression because the DB seeds factory defaults before the backup is restored, and the first write then exported those defaults over the only off-device copy. Storage permission is now settled before the DB opens, a defaults-only DB refuses to overwrite a richer backup, and the three swallowed storage errors say what broke. Sessions that exist only in Firebase/GitHub are restored into local history, so a reinstalled app has the history it actually earned rather than starting at whatever survived. Matched on (date, start_time), so re-syncing never duplicates and local rows always win. Adds a target-reps editor: progression can only ever RAISE reps, so a rep target a defaults re-seed set wrong could not be corrected from the UI at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
This commit is contained in:
parent
b008992cb7
commit
e7fa7fb44c
@ -4,9 +4,13 @@
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
|
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
|
||||||
EARLY_BIRD_TIMER_FILE="$SCRIPT_DIR/early-bird-workout-check.timer"
|
EARLY_BIRD_TIMER_FILE="$SCRIPT_DIR/early-bird-workout-check.timer"
|
||||||
|
SYNC_SERVICE_FILE="$SCRIPT_DIR/workout-sync.service"
|
||||||
|
SYNC_TIMER_FILE="$SCRIPT_DIR/workout-sync.timer"
|
||||||
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
||||||
SERVICE_NAME="workout-locker.service"
|
SERVICE_NAME="workout-locker.service"
|
||||||
EARLY_BIRD_TIMER_NAME="early-bird-workout-check.timer"
|
EARLY_BIRD_TIMER_NAME="early-bird-workout-check.timer"
|
||||||
|
SYNC_SERVICE_NAME="workout-sync.service"
|
||||||
|
SYNC_TIMER_NAME="workout-sync.timer"
|
||||||
|
|
||||||
# Check if service is already installed
|
# Check if service is already installed
|
||||||
if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then
|
if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then
|
||||||
@ -44,6 +48,12 @@ cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
|||||||
# Copy early bird timer
|
# Copy early bird timer
|
||||||
cp "$EARLY_BIRD_TIMER_FILE" "$USER_SERVICE_DIR/$EARLY_BIRD_TIMER_NAME"
|
cp "$EARLY_BIRD_TIMER_FILE" "$USER_SERVICE_DIR/$EARLY_BIRD_TIMER_NAME"
|
||||||
|
|
||||||
|
# Copy the periodic workout-sync units. Without these, syncing happens only
|
||||||
|
# once per locker start, so a workout finished after login is not seen until
|
||||||
|
# the next login.
|
||||||
|
cp "$SYNC_SERVICE_FILE" "$USER_SERVICE_DIR/$SYNC_SERVICE_NAME"
|
||||||
|
cp "$SYNC_TIMER_FILE" "$USER_SERVICE_DIR/$SYNC_TIMER_NAME"
|
||||||
|
|
||||||
# Update paths in the service file to use absolute paths
|
# Update paths in the service file to use absolute paths
|
||||||
REPO_ROOT="$SCRIPT_DIR"
|
REPO_ROOT="$SCRIPT_DIR"
|
||||||
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
||||||
@ -59,6 +69,9 @@ systemctl --user enable "$SERVICE_NAME"
|
|||||||
# Enable the early bird re-check timer
|
# Enable the early bird re-check timer
|
||||||
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME"
|
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME"
|
||||||
|
|
||||||
|
# Enable the periodic workout sync
|
||||||
|
systemctl --user enable --now "$SYNC_TIMER_NAME"
|
||||||
|
|
||||||
echo "✓ Workout locker service installed"
|
echo "✓ Workout locker service installed"
|
||||||
echo "✓ Early bird re-check timer installed (fires daily at 08:30)"
|
echo "✓ Early bird re-check timer installed (fires daily at 08:30)"
|
||||||
echo "✓ Service will start automatically on next login"
|
echo "✓ Service will start automatically on next login"
|
||||||
@ -81,6 +94,11 @@ if systemctl --user is-enabled "$EARLY_BIRD_TIMER_NAME" &>/dev/null; then
|
|||||||
else
|
else
|
||||||
echo "✗ early bird timer: NOT enabled"
|
echo "✗ early bird timer: NOT enabled"
|
||||||
fi
|
fi
|
||||||
|
if systemctl --user is-enabled "$SYNC_TIMER_NAME" &>/dev/null; then
|
||||||
|
echo "✓ workout sync timer: INSTALLED and enabled (every 15 min)"
|
||||||
|
else
|
||||||
|
echo "✗ workout sync timer: NOT enabled"
|
||||||
|
fi
|
||||||
|
|
||||||
I3_CONFIG="$HOME/.config/i3/config"
|
I3_CONFIG="$HOME/.config/i3/config"
|
||||||
if [ -f "$I3_CONFIG" ] && grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
|
if [ -f "$I3_CONFIG" ] && grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
|
||||||
|
|||||||
@ -8,7 +8,7 @@ requires-python = ">=3.10"
|
|||||||
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
|
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
|
||||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.6.0#subdirectory=crdt-sync",
|
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.7.0#subdirectory=crdt-sync",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@ -5,19 +5,23 @@ SERVICE_NAME="workout-locker.service"
|
|||||||
TIMER_NAME="workout-locker.timer"
|
TIMER_NAME="workout-locker.timer"
|
||||||
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
||||||
|
|
||||||
# Stop the service and timer if running
|
# Every unit install_systemd.sh creates. Removal has to enumerate all of them,
|
||||||
systemctl --user stop "$TIMER_NAME" 2>/dev/null
|
# or a "removed" locker keeps firing from a timer nobody remembers installing.
|
||||||
systemctl --user stop "$SERVICE_NAME" 2>/dev/null
|
UNITS=(
|
||||||
|
"$SERVICE_NAME"
|
||||||
|
"$TIMER_NAME"
|
||||||
|
"early-bird-workout-check.timer"
|
||||||
|
"workout-sync.timer"
|
||||||
|
"workout-sync.service"
|
||||||
|
)
|
||||||
|
|
||||||
# Disable the service and timer
|
for unit in "${UNITS[@]}"; do
|
||||||
systemctl --user disable "$TIMER_NAME" 2>/dev/null
|
systemctl --user stop "$unit" 2>/dev/null
|
||||||
systemctl --user disable "$SERVICE_NAME" 2>/dev/null
|
systemctl --user disable "$unit" 2>/dev/null
|
||||||
|
rm -f "$USER_SERVICE_DIR/$unit"
|
||||||
# Remove service and timer files
|
done
|
||||||
rm -f "$USER_SERVICE_DIR/$SERVICE_NAME"
|
|
||||||
rm -f "$USER_SERVICE_DIR/$TIMER_NAME"
|
|
||||||
|
|
||||||
# Reload systemd daemon
|
# Reload systemd daemon
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
echo "✓ Workout locker service and timer removed"
|
echo "✓ Workout locker services and timers removed"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
bandit>=1.7.0
|
bandit>=1.7.0
|
||||||
codespell>=2.2.0
|
codespell>=2.2.0
|
||||||
coverage>=7.4.0
|
coverage>=7.4.0
|
||||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.6.0#subdirectory=crdt-sync
|
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.7.0#subdirectory=crdt-sync
|
||||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
|
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
|
||||||
mcp>=2,<3
|
mcp>=2,<3
|
||||||
mypy>=1.8.0
|
mypy>=1.8.0
|
||||||
|
|||||||
@ -11,8 +11,13 @@ Retrying at the application layer rather than ordering the unit after
|
|||||||
ordering does not apply, and the user services involved cannot depend on a
|
ordering does not apply, and the user services involved cannot depend on a
|
||||||
system target anyway.
|
system target anyway.
|
||||||
|
|
||||||
Any :class:`GitHubSyncError` is retried, deliberately including
|
Any :class:`RemoteSyncError` is retried, deliberately including
|
||||||
:class:`RepoNotFoundError`. Classifying "transient" vs "permanent" would mean
|
:class:`RepoNotFoundError`. Catching the common parent rather than
|
||||||
|
:class:`GitHubSyncError` matters since the Firebase cutover:
|
||||||
|
:class:`FirebaseSyncError` is a *sibling* of :class:`GitHubSyncError`, not a
|
||||||
|
subclass, so catching the latter let every Firebase failure escape uncaught and
|
||||||
|
take the whole locker down (``status=1/FAILURE``) instead of degrading to "no
|
||||||
|
synced workout". Classifying "transient" vs "permanent" would mean
|
||||||
depending on which exception ``crdt_sync`` chains where, which is internal to
|
depending on which exception ``crdt_sync`` chains where, which is internal to
|
||||||
that library and has changed before; a few wasted seconds on a genuinely
|
that library and has changed before; a few wasted seconds on a genuinely
|
||||||
misconfigured token is a much cheaper mistake than skipping a real retry.
|
misconfigured token is a much cheaper mistake than skipping a real retry.
|
||||||
@ -24,7 +29,7 @@ import logging
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import TYPE_CHECKING, TypeVar
|
from typing import TYPE_CHECKING, TypeVar
|
||||||
|
|
||||||
from crdt_sync import GitHubSyncError
|
from crdt_sync import RemoteSyncError
|
||||||
|
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
SYNC_RETRY_ATTEMPTS,
|
SYNC_RETRY_ATTEMPTS,
|
||||||
@ -60,7 +65,7 @@ def with_sync_retry(
|
|||||||
Whatever *operation* returns.
|
Whatever *operation* returns.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
GitHubSyncError: If every attempt failed. The caller keeps its own
|
RemoteSyncError: If every attempt failed. The caller keeps its own
|
||||||
handling — this only buys time, it never hides the outcome.
|
handling — this only buys time, it never hides the outcome.
|
||||||
"""
|
"""
|
||||||
last_attempt = max(attempts, 1)
|
last_attempt = max(attempts, 1)
|
||||||
@ -69,7 +74,7 @@ def with_sync_retry(
|
|||||||
for attempt in range(1, last_attempt):
|
for attempt in range(1, last_attempt):
|
||||||
try:
|
try:
|
||||||
return operation()
|
return operation()
|
||||||
except GitHubSyncError as exc:
|
except RemoteSyncError as exc:
|
||||||
wait = delay_seconds * (2 ** (attempt - 1))
|
wait = delay_seconds * (2 ** (attempt - 1))
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Could not %s (attempt %d/%d): %s — retrying in %.0fs; "
|
"Could not %s (attempt %d/%d): %s — retrying in %.0fs; "
|
||||||
@ -85,7 +90,7 @@ def with_sync_retry(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
return operation()
|
return operation()
|
||||||
except GitHubSyncError as exc:
|
except RemoteSyncError as exc:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Could not %s after %d attempt(s): %s — giving up",
|
"Could not %s after %d attempt(s): %s — giving up",
|
||||||
description,
|
description,
|
||||||
|
|||||||
@ -12,22 +12,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from crdt_sync import (
|
from crdt_sync import (
|
||||||
CONFIG_FILE,
|
CONFIG_FILE,
|
||||||
ConfigError,
|
ConfigError,
|
||||||
FirebaseAuthError,
|
FirebaseAuthError,
|
||||||
GitHubSyncClient,
|
GitHubSyncClient,
|
||||||
GitHubSyncError,
|
|
||||||
Hlc,
|
Hlc,
|
||||||
Record,
|
Record,
|
||||||
RemoteStore,
|
RemoteStore,
|
||||||
RemoteSyncError,
|
RemoteSyncError,
|
||||||
|
firebase_client_for,
|
||||||
mirror_client_for,
|
mirror_client_for,
|
||||||
)
|
)
|
||||||
|
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
SYNC_PHONE_DEVICE_ID,
|
|
||||||
SYNC_REPO_NAME,
|
SYNC_REPO_NAME,
|
||||||
SYNC_REPO_OWNER,
|
SYNC_REPO_OWNER,
|
||||||
SYNC_TIMEOUT_SECONDS,
|
SYNC_TIMEOUT_SECONDS,
|
||||||
@ -36,10 +36,12 @@ from screen_locker._constants import (
|
|||||||
from screen_locker._manual_workout import MANUAL_WORKOUT_SYNC_KIND
|
from screen_locker._manual_workout import MANUAL_WORKOUT_SYNC_KIND
|
||||||
from screen_locker._sync_retry import with_sync_retry
|
from screen_locker._sync_retry import with_sync_retry
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DEVICES_PREFIX = "screen-locker-sync/devices"
|
_DEVICES_PREFIX = "screen-locker-sync/devices"
|
||||||
_LOG_PATH = f"{_DEVICES_PREFIX}/{SYNC_PHONE_DEVICE_ID}/log.json"
|
|
||||||
_PAYLOAD_FIELD = "payload"
|
_PAYLOAD_FIELD = "payload"
|
||||||
|
|
||||||
|
|
||||||
@ -48,6 +50,22 @@ def _is_manual_payload(payload: object) -> bool:
|
|||||||
return isinstance(payload, dict) and payload.get("kind") == MANUAL_WORKOUT_SYNC_KIND
|
return isinstance(payload, dict) and payload.get("kind") == MANUAL_WORKOUT_SYNC_KIND
|
||||||
|
|
||||||
|
|
||||||
|
def _is_session_payload(payload: object) -> bool:
|
||||||
|
"""True if a decoded sync payload is a completed StrongLifts session.
|
||||||
|
|
||||||
|
Identified by SHAPE -- the presence of ``exercises`` -- not by a ``kind``
|
||||||
|
field, because the phone app's ``WorkoutSession.toJson()`` emits no ``kind``
|
||||||
|
at all. Keying session detection on ``kind`` is what made real sessions
|
||||||
|
invisible to the only reader that walks every device directory.
|
||||||
|
|
||||||
|
Shape-matching also excludes the PC-origin ``runnerup_verified`` /
|
||||||
|
``phone_verified`` records that share these device logs: they are non-manual
|
||||||
|
too, so "not manual" alone would hand a caller expecting ``exercises`` and
|
||||||
|
``duration_seconds`` a record that has neither.
|
||||||
|
"""
|
||||||
|
return isinstance(payload, dict) and isinstance(payload.get("exercises"), list)
|
||||||
|
|
||||||
|
|
||||||
def read_sync_token() -> str | None:
|
def read_sync_token() -> str | None:
|
||||||
"""Return the saved sync PAT, or None if sync isn't configured.
|
"""Return the saved sync PAT, or None if sync isn't configured.
|
||||||
|
|
||||||
@ -61,38 +79,81 @@ def read_sync_token() -> str | None:
|
|||||||
return token or None
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
def _latest_payload(log_json: str) -> dict | None:
|
def _merge_device_records(
|
||||||
"""Return the most recently pushed workout payload from a log.json blob.
|
client: RemoteStore,
|
||||||
|
extract: Callable[[str], dict[str, tuple[dict, Hlc]]],
|
||||||
|
what: str,
|
||||||
|
*,
|
||||||
|
strict: bool = False,
|
||||||
|
) -> dict[str, tuple[dict, Hlc]]:
|
||||||
|
"""Merge ``extract``-selected records across EVERY device log.
|
||||||
|
|
||||||
Raises:
|
Walks ``devices/<id>/log.json`` for every device directory, keeping the
|
||||||
TypeError: If the top-level JSON isn't an object, a record's shape
|
highest-HLC copy of each record id -- records are id-stable, so the same
|
||||||
doesn't match what :meth:`Record.from_dict` expects, or the
|
workout mirrored into two device logs dedups to one.
|
||||||
payload field itself isn't a JSON object.
|
|
||||||
KeyError: If a record is missing an expected key.
|
Every device is walked because the phone's directory name is a per-install
|
||||||
ValueError: Via ``json.loads``, if the text isn't valid JSON, or via
|
uuid (since ffc6c72), not a fixed role id: it changes on every reinstall,
|
||||||
:meth:`crdt_sync.Hlc.from_str` on a malformed clock string.
|
and old directories linger. Reading any single hardcoded device path is
|
||||||
|
guaranteed to go stale, which is exactly how phone sessions stopped
|
||||||
|
reaching this machine.
|
||||||
|
|
||||||
|
Best-effort per device: an unreachable or corrupt device log yields fewer
|
||||||
|
records rather than raising, and says so at ``warning`` -- naming the
|
||||||
|
device and what is being dropped -- so a partial pull is never silent.
|
||||||
|
|
||||||
|
``strict`` controls only the *top-level* listing failure, where nothing at
|
||||||
|
all could be read. Callers that distinguish "nothing to sync" from "the
|
||||||
|
sync broke" pass ``strict=True`` to get the exception instead of an empty
|
||||||
|
dict, since an empty dict would otherwise report a total failure as the
|
||||||
|
benign "no records" case.
|
||||||
"""
|
"""
|
||||||
raw = json.loads(log_json)
|
try:
|
||||||
if not isinstance(raw, dict):
|
devices = with_sync_retry(
|
||||||
msg = f"top-level sync payload is not a JSON object: {raw!r}"
|
lambda: client.list_directory(_DEVICES_PREFIX),
|
||||||
raise TypeError(msg)
|
description="list synced device logs",
|
||||||
|
)
|
||||||
|
except RemoteSyncError as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not list device logs under %s in %s/%s: %s — pulling NO "
|
||||||
|
"synced %s, so phone-logged workouts will not count",
|
||||||
|
_DEVICES_PREFIX,
|
||||||
|
SYNC_REPO_OWNER,
|
||||||
|
SYNC_REPO_NAME,
|
||||||
|
exc,
|
||||||
|
what,
|
||||||
|
)
|
||||||
|
if strict:
|
||||||
|
raise
|
||||||
|
return {}
|
||||||
|
|
||||||
records = [Record.from_dict(data) for data in raw.values()]
|
merged: dict[str, tuple[dict, Hlc]] = {}
|
||||||
candidates = [
|
for device in devices:
|
||||||
record
|
path = f"{_DEVICES_PREFIX}/{device}/log.json"
|
||||||
for record in records
|
try:
|
||||||
if _PAYLOAD_FIELD in record.fields
|
text = client.get_file_text(path)
|
||||||
and not _is_manual_payload(record.fields[_PAYLOAD_FIELD][0])
|
except RemoteSyncError as exc:
|
||||||
]
|
_logger.warning(
|
||||||
if not candidates:
|
"Could not fetch device log %s: %s — SKIPPING that device, so "
|
||||||
return None
|
"any %s it holds will not count",
|
||||||
latest = max(candidates, key=lambda record: record.fields[_PAYLOAD_FIELD][1])
|
path,
|
||||||
|
exc,
|
||||||
|
what,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if text is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
records = extract(text)
|
||||||
|
except (ValueError, KeyError, TypeError) as exc:
|
||||||
|
_logger.warning("Corrupt sync data at %s: %s", path, exc)
|
||||||
|
continue
|
||||||
|
for rid, (payload, hlc) in records.items():
|
||||||
|
existing = merged.get(rid)
|
||||||
|
if existing is None or existing[1] < hlc:
|
||||||
|
merged[rid] = (payload, hlc)
|
||||||
|
|
||||||
payload = latest.fields[_PAYLOAD_FIELD][0]
|
return merged
|
||||||
if not isinstance(payload, dict):
|
|
||||||
msg = f"payload field is not a JSON object: {payload!r}"
|
|
||||||
raise TypeError(msg)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def remote_client(github: RemoteStore) -> RemoteStore:
|
def remote_client(github: RemoteStore) -> RemoteStore:
|
||||||
@ -121,70 +182,112 @@ def remote_client(github: RemoteStore) -> RemoteStore:
|
|||||||
return github
|
return github
|
||||||
|
|
||||||
|
|
||||||
def pull_synced_workout() -> tuple[dict | None, str | None]:
|
def sync_client() -> RemoteStore | None:
|
||||||
"""Return ``(data, error)``: the phone's last-synced workout, if any.
|
"""Return the configured read client, or None if sync is set up nowhere.
|
||||||
|
|
||||||
- No token configured: ``(None, None)`` -- benign, sync is optional.
|
A GitHub token is no longer required: Firebase has been the primary backend
|
||||||
- Nothing pushed yet (repo reachable, path unused): ``(None, None)``.
|
since eb4ff01, so a Firebase-only machine must still sync. Previously this
|
||||||
- A real sync error (network, bad token, repo unreachable):
|
module returned early whenever the PAT was missing, reporting "sync is OFF"
|
||||||
``(None, <message>)``.
|
on a machine whose sync was working perfectly -- a false negative that hid
|
||||||
- Corrupt/malformed sync data: ``(None, "corrupt sync data: ...")``.
|
a live backend behind a legacy credential check.
|
||||||
- Success: ``(payload_dict, None)``.
|
|
||||||
|
GitHub is used alone when only the PAT exists, Firebase alone when only
|
||||||
|
``~/.config/crdt-sync/`` exists, and the mirrored union when both do.
|
||||||
|
``None`` means neither is configured, which stays a benign, expected state.
|
||||||
"""
|
"""
|
||||||
token = read_sync_token()
|
token = read_sync_token()
|
||||||
if token is None:
|
github = (
|
||||||
_logger.warning(
|
|
||||||
"Cannot pull the synced StrongLifts session: no sync token at %s — "
|
|
||||||
"GitHub sync is OFF, so only ADB/HTTP can verify a phone workout. "
|
|
||||||
"Create a fine-grained PAT with contents:write on %s/%s and save it "
|
|
||||||
"there (chmod 600)",
|
|
||||||
SYNC_TOKEN_FILE,
|
|
||||||
SYNC_REPO_OWNER,
|
|
||||||
SYNC_REPO_NAME,
|
|
||||||
)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
client = remote_client(
|
|
||||||
GitHubSyncClient(
|
GitHubSyncClient(
|
||||||
SYNC_REPO_OWNER,
|
SYNC_REPO_OWNER,
|
||||||
SYNC_REPO_NAME,
|
SYNC_REPO_NAME,
|
||||||
token,
|
token,
|
||||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
if token is not None
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
try:
|
if github is not None:
|
||||||
text = with_sync_retry(
|
return remote_client(github)
|
||||||
lambda: client.get_file_text(_LOG_PATH),
|
if not CONFIG_FILE.is_file():
|
||||||
description="fetch the phone's workout log",
|
|
||||||
)
|
|
||||||
except GitHubSyncError as exc:
|
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Could not fetch the phone's workout log %s from %s/%s: %s — "
|
"Cannot pull synced workouts: no sync token at %s and no Firebase "
|
||||||
"falling back to ADB/HTTP verification",
|
"config at %s — sync is OFF, so only ADB/HTTP can verify a phone "
|
||||||
_LOG_PATH,
|
"workout and phone-logged workouts will NOT count here",
|
||||||
SYNC_REPO_OWNER,
|
SYNC_TOKEN_FILE,
|
||||||
SYNC_REPO_NAME,
|
CONFIG_FILE,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return firebase_client_for("screen_locker")
|
||||||
|
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Firebase is configured at %s but unusable, and there is no GitHub "
|
||||||
|
"token at %s to fall back to: %s — pulling NO synced workouts",
|
||||||
|
CONFIG_FILE,
|
||||||
|
SYNC_TOKEN_FILE,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pull_synced_workout() -> tuple[dict | None, str | None]:
|
||||||
|
"""Return ``(data, error)``: the last-synced StrongLifts session, if any.
|
||||||
|
|
||||||
|
Merges sessions across EVERY device directory and returns the highest-HLC
|
||||||
|
one. It used to read a single hardcoded ``devices/phone/log.json``, which
|
||||||
|
silently went stale the moment the phone moved to a per-install uuid
|
||||||
|
(ffc6c72): this machine kept happily returning a session from 2026-07-27
|
||||||
|
while newer ones sat unread one directory over.
|
||||||
|
|
||||||
|
- Nothing configured anywhere: ``(None, None)`` -- benign, sync is optional.
|
||||||
|
- Nothing pushed yet: ``(None, None)``.
|
||||||
|
- A real sync error (network, bad token, backend unreachable):
|
||||||
|
``(None, <message>)``.
|
||||||
|
- Success: ``(payload_dict, None)``.
|
||||||
|
"""
|
||||||
|
client = sync_client()
|
||||||
|
if client is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
merged = _merge_device_records(
|
||||||
|
client, _session_records, "StrongLifts sessions", strict=True
|
||||||
|
)
|
||||||
|
except RemoteSyncError as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not fetch synced workout logs under %s: %s — falling back "
|
||||||
|
"to ADB/HTTP verification",
|
||||||
|
_DEVICES_PREFIX,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return None, str(exc)
|
return None, str(exc)
|
||||||
|
|
||||||
if text is None:
|
if not merged:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
try:
|
_rid, (payload, _hlc) = max(merged.items(), key=lambda item: item[1][1])
|
||||||
payload = _latest_payload(text)
|
|
||||||
except (ValueError, KeyError, TypeError) as exc:
|
|
||||||
_logger.warning("Corrupt sync data at %s: %s", _LOG_PATH, exc)
|
|
||||||
return None, f"corrupt sync data: {exc}"
|
|
||||||
|
|
||||||
return payload, None
|
return payload, None
|
||||||
|
|
||||||
|
|
||||||
def _manual_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
|
def _session_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
|
||||||
"""Return ``{record_id: (payload, hlc)}`` for manual records in a log blob.
|
"""Return ``{record_id: (payload, hlc)}`` for session records in a log blob.
|
||||||
|
|
||||||
Raises the same decode errors as :func:`_latest_payload`; callers treat a
|
Raises the same decode errors as :func:`_manual_records`; callers treat a
|
||||||
corrupt device log as "no manual records from that device".
|
corrupt device log as "no sessions from that device".
|
||||||
|
"""
|
||||||
|
return _records_matching(log_json, _is_session_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _records_matching(
|
||||||
|
log_json: str, predicate: Callable[[object], bool]
|
||||||
|
) -> dict[str, tuple[dict, Hlc]]:
|
||||||
|
"""Return ``{record_id: (payload, hlc)}`` for payloads passing ``predicate``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If the top-level JSON isn't an object or a record's shape
|
||||||
|
doesn't match what :meth:`Record.from_dict` expects.
|
||||||
|
KeyError: If a record is missing an expected key.
|
||||||
|
ValueError: Via ``json.loads`` or :meth:`crdt_sync.Hlc.from_str`.
|
||||||
"""
|
"""
|
||||||
raw = json.loads(log_json)
|
raw = json.loads(log_json)
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
@ -197,11 +300,20 @@ def _manual_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
|
|||||||
if field is None:
|
if field is None:
|
||||||
continue
|
continue
|
||||||
payload, hlc = field
|
payload, hlc = field
|
||||||
if _is_manual_payload(payload):
|
if predicate(payload):
|
||||||
result[record.id] = (payload, hlc)
|
result[record.id] = (payload, hlc)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
|
||||||
|
"""Return ``{record_id: (payload, hlc)}`` for manual records in a log blob.
|
||||||
|
|
||||||
|
Raises the same decode errors as :func:`_records_matching`; callers treat a
|
||||||
|
corrupt device log as "no manual records from that device".
|
||||||
|
"""
|
||||||
|
return _records_matching(log_json, _is_manual_payload)
|
||||||
|
|
||||||
|
|
||||||
def pull_all_manual_records() -> list[tuple[str, dict]]:
|
def pull_all_manual_records() -> list[tuple[str, dict]]:
|
||||||
"""Return all synced manual-workout records across every device log.
|
"""Return all synced manual-workout records across every device log.
|
||||||
|
|
||||||
@ -212,66 +324,9 @@ def pull_all_manual_records() -> list[tuple[str, dict]]:
|
|||||||
log yields fewer/no records rather than raising — manual sync, like session
|
log yields fewer/no records rather than raising — manual sync, like session
|
||||||
sync, is optional.
|
sync, is optional.
|
||||||
"""
|
"""
|
||||||
token = read_sync_token()
|
client = sync_client()
|
||||||
if token is None:
|
if client is None:
|
||||||
_logger.warning(
|
|
||||||
"Pulling NO synced manual workouts: no sync token at %s — GitHub "
|
|
||||||
"sync is OFF, so workouts logged on the phone will NOT count here. "
|
|
||||||
"Create a fine-grained PAT with contents:write on %s/%s and save it "
|
|
||||||
"there (chmod 600)",
|
|
||||||
SYNC_TOKEN_FILE,
|
|
||||||
SYNC_REPO_OWNER,
|
|
||||||
SYNC_REPO_NAME,
|
|
||||||
)
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
client = remote_client(
|
merged = _merge_device_records(client, _manual_records, "manual workouts")
|
||||||
GitHubSyncClient(
|
|
||||||
SYNC_REPO_OWNER,
|
|
||||||
SYNC_REPO_NAME,
|
|
||||||
token,
|
|
||||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
devices = with_sync_retry(
|
|
||||||
lambda: client.list_directory(_DEVICES_PREFIX),
|
|
||||||
description="list synced device logs",
|
|
||||||
)
|
|
||||||
except GitHubSyncError as exc:
|
|
||||||
_logger.warning(
|
|
||||||
"Could not list device logs under %s in %s/%s: %s — pulling NO "
|
|
||||||
"synced manual workouts, so phone-logged workouts will not count",
|
|
||||||
_DEVICES_PREFIX,
|
|
||||||
SYNC_REPO_OWNER,
|
|
||||||
SYNC_REPO_NAME,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
merged: dict[str, tuple[dict, Hlc]] = {}
|
|
||||||
for device in devices:
|
|
||||||
path = f"{_DEVICES_PREFIX}/{device}/log.json"
|
|
||||||
try:
|
|
||||||
text = client.get_file_text(path)
|
|
||||||
except GitHubSyncError as exc:
|
|
||||||
_logger.warning(
|
|
||||||
"Could not fetch device log %s: %s — SKIPPING that device, so "
|
|
||||||
"any manual workouts it holds will not count",
|
|
||||||
path,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if text is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
records = _manual_records(text)
|
|
||||||
except (ValueError, KeyError, TypeError) as exc:
|
|
||||||
_logger.warning("Corrupt sync data at %s: %s", path, exc)
|
|
||||||
continue
|
|
||||||
for rid, (payload, hlc) in records.items():
|
|
||||||
existing = merged.get(rid)
|
|
||||||
if existing is None or existing[1] < hlc:
|
|
||||||
merged[rid] = (payload, hlc)
|
|
||||||
|
|
||||||
return [(rid, payload) for rid, (payload, _hlc) in merged.items()]
|
return [(rid, payload) for rid, (payload, _hlc) in merged.items()]
|
||||||
|
|||||||
@ -227,6 +227,16 @@ class ScreenLocker(
|
|||||||
# shutdown-time-only reward (see _apply_weekly_shutdown_bonus).
|
# shutdown-time-only reward (see _apply_weekly_shutdown_bonus).
|
||||||
self._check_heat_skip_exit()
|
self._check_heat_skip_exit()
|
||||||
|
|
||||||
|
def sync_now(self) -> None:
|
||||||
|
"""Run one sync pass: publish this PC's workouts, ingest everyone's.
|
||||||
|
|
||||||
|
The public entry point behind ``--sync-only``, which the
|
||||||
|
``workout-sync.timer`` unit calls. Sync used to happen only inside the
|
||||||
|
locker's startup path, so a workout finished after login stayed
|
||||||
|
invisible until the next login.
|
||||||
|
"""
|
||||||
|
self._ingest_synced_manual_workouts()
|
||||||
|
|
||||||
def _ingest_synced_manual_workouts(self) -> None:
|
def _ingest_synced_manual_workouts(self) -> None:
|
||||||
"""Sync manual workouts: publish this PC's, ingest everyone else's.
|
"""Sync manual workouts: publish this PC's, ingest everyone else's.
|
||||||
|
|
||||||
@ -342,6 +352,24 @@ if __name__ == "__main__":
|
|||||||
_sl.workout_data = {}
|
_sl.workout_data = {}
|
||||||
run_status(_sl)
|
run_status(_sl)
|
||||||
|
|
||||||
|
if "--sync-only" in sys.argv:
|
||||||
|
# Headless sync for the timer unit: pull other devices' workouts and
|
||||||
|
# apply their credit, with no Tk and no lock screen. Sync used to run
|
||||||
|
# ONLY at process start, so a workout finished after login was not seen
|
||||||
|
# until the next login — the timer closes that window.
|
||||||
|
#
|
||||||
|
# Same __init__ bypass as --status: this path touches only log_file and
|
||||||
|
# workout_data, and constructing the real ScreenLocker would build a UI.
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
_sl = object.__new__(ScreenLocker)
|
||||||
|
_sl.log_file = Path(__file__).resolve().parent / "workout_log.json"
|
||||||
|
_sl.workout_data = {}
|
||||||
|
_sl.sync_now()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
demo_mode = True
|
demo_mode = True
|
||||||
verify_only = "--verify-workout" in sys.argv
|
verify_only = "--verify-workout" in sys.argv
|
||||||
|
|
||||||
|
|||||||
97
screen_locker/tests/_workout_sync_fixtures.py
Normal file
97
screen_locker/tests/_workout_sync_fixtures.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
"""Record/payload builders shared by the workout-sync test modules.
|
||||||
|
|
||||||
|
Split out of ``test_workout_sync`` (and imported by it and its ``_part2``)
|
||||||
|
so neither file exceeds the repo's 400-line limit. Everything here builds
|
||||||
|
crdt-sync wire data the way the phone app actually emits it -- in
|
||||||
|
particular ``_session_payload`` carries NO ``kind`` field, because session
|
||||||
|
detection is by shape, and a fixture that invented a ``kind`` would hide
|
||||||
|
the very bug these tests exist to pin down.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from crdt_sync import Hlc, Record
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _record_json(payload: dict, *, wall_time_ms: int = 1000) -> dict:
|
||||||
|
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
|
||||||
|
record = Record(id="x", fields={"payload": (payload, hlc)})
|
||||||
|
return record.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _session_payload(**extra: object) -> dict:
|
||||||
|
"""A StrongLifts session as the phone app's ``toJson()`` actually emits it.
|
||||||
|
|
||||||
|
Deliberately carries NO ``kind`` field: session detection is by shape (an
|
||||||
|
``exercises`` list), and keying it on ``kind`` is the bug that made real
|
||||||
|
sessions invisible.
|
||||||
|
"""
|
||||||
|
payload: dict = {
|
||||||
|
"exercises": [{"name": "Squat", "sets": 5}],
|
||||||
|
"duration_seconds": 1800,
|
||||||
|
}
|
||||||
|
payload.update(extra)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _session_record_dict(
|
||||||
|
record_id: str, payload: dict, *, wall_time_ms: int = 1000
|
||||||
|
) -> dict:
|
||||||
|
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
|
||||||
|
return Record(id=record_id, fields={"payload": (payload, hlc)}).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_payload(**extra: object) -> dict:
|
||||||
|
payload = {"kind": "manual_workout", "date": "2026-07-13"}
|
||||||
|
payload.update(extra)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_record_dict(
|
||||||
|
record_id: str, payload: dict, *, wall_time_ms: int = 1000
|
||||||
|
) -> dict:
|
||||||
|
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
|
||||||
|
return Record(id=record_id, fields={"payload": (payload, hlc)}).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _multi_device_client(device_logs: dict[str, object]) -> MagicMock:
|
||||||
|
"""A client whose ``devices/<id>/log.json`` tree is ``device_logs``.
|
||||||
|
|
||||||
|
An ``Exception`` value is raised on fetch, so a single dict describes
|
||||||
|
healthy, missing and unreachable devices at once.
|
||||||
|
"""
|
||||||
|
client = MagicMock()
|
||||||
|
client.list_directory.return_value = list(device_logs)
|
||||||
|
|
||||||
|
def _get(path: str) -> object:
|
||||||
|
device = path.split("/")[-2]
|
||||||
|
value = device_logs[device]
|
||||||
|
if isinstance(value, Exception):
|
||||||
|
raise value
|
||||||
|
return value
|
||||||
|
|
||||||
|
client.get_file_text.side_effect = _get
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _firebase_config(
|
||||||
|
module: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> Path:
|
||||||
|
"""Point CONFIG_FILE at a real file so the Firebase branch is taken.
|
||||||
|
|
||||||
|
The autouse ``_no_real_firebase_config`` fixture aims it at a nonexistent
|
||||||
|
path, so any test wanting the Firebase side must opt back in explicitly.
|
||||||
|
"""
|
||||||
|
config = tmp_path / "firebase.json"
|
||||||
|
config.write_text("{}", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(module, "CONFIG_FILE", config)
|
||||||
|
return config
|
||||||
@ -342,3 +342,31 @@ class TestTryAutoUpgradeEarlyBirdRunnerUp:
|
|||||||
MagicMock(return_value=("not_verified", "no run")),
|
MagicMock(return_value=("not_verified", "no run")),
|
||||||
)
|
)
|
||||||
assert locker._try_auto_upgrade_early_bird() is False
|
assert locker._try_auto_upgrade_early_bird() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncNow:
|
||||||
|
"""Tests for sync_now, the public entry point behind ``--sync-only``."""
|
||||||
|
|
||||||
|
def test_runs_one_full_sync_pass(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""The timer unit's entry point must actually push and ingest.
|
||||||
|
|
||||||
|
``workout-sync.timer`` calls this headlessly; if it stopped delegating,
|
||||||
|
the timer would run and silently sync nothing.
|
||||||
|
"""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"screen_locker.screen_lock.pull_all_manual_records",
|
||||||
|
return_value=[("manual:x", {})],
|
||||||
|
),
|
||||||
|
patch("screen_locker.screen_lock.push_pc_workouts") as push,
|
||||||
|
patch(
|
||||||
|
"screen_locker.screen_lock.ingest_manual_records",
|
||||||
|
return_value=["manual:x"],
|
||||||
|
) as ingest,
|
||||||
|
):
|
||||||
|
locker.sync_now()
|
||||||
|
push.assert_called_once()
|
||||||
|
ingest.assert_called_once()
|
||||||
|
|||||||
@ -1,25 +1,35 @@
|
|||||||
"""Tests for the phone-workout GitHub sync pull (crdt-sync transport)."""
|
"""Tests for the phone-workout sync pull (crdt-sync transport).
|
||||||
|
|
||||||
|
Covers the token read, the payload predicates, the per-log record extractors
|
||||||
|
and the cross-device merge. The backend selection (``sync_client`` /
|
||||||
|
``remote_client``) and the two public pulls live in ``_part2``.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from unittest.mock import MagicMock
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from crdt_sync import ConfigError, GitHubSyncError, Hlc, Record
|
from crdt_sync import GitHubSyncError, Hlc, Record
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from screen_locker import _workout_sync
|
from screen_locker import _workout_sync
|
||||||
|
from screen_locker.tests._workout_sync_fixtures import (
|
||||||
|
_manual_payload,
|
||||||
|
_manual_record_dict,
|
||||||
|
_multi_device_client,
|
||||||
|
_record_json,
|
||||||
|
_session_payload,
|
||||||
|
_session_record_dict,
|
||||||
|
)
|
||||||
|
|
||||||
# The autouse `_isolate_sync_token` fixture in conftest.py already redirects
|
# The autouse `isolate_sync_token` fixture (registered repo-wide via the
|
||||||
# `_workout_sync.SYNC_TOKEN_FILE` into a per-test tmp_path, so tests here can
|
# `pytest_plugins` entry for `screen_locker.tests.test_sync_fixtures` in
|
||||||
# write to it directly without needing their own isolation fixture.
|
# pyproject.toml) already redirects `_workout_sync.SYNC_TOKEN_FILE` into a
|
||||||
|
# per-test tmp_path, so tests here can write to it directly without needing
|
||||||
|
# their own isolation fixture. The autouse `_no_real_firebase_config` fixture
|
||||||
def _record_json(payload: dict, *, wall_time_ms: int = 1000) -> dict:
|
# in conftest.py likewise points `CONFIG_FILE` at a nonexistent path, so tests
|
||||||
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
|
# that want the Firebase branch must monkeypatch it back at a file they own.
|
||||||
record = Record(id="x", fields={"payload": (payload, hlc)})
|
|
||||||
return record.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
class TestReadSyncToken:
|
class TestReadSyncToken:
|
||||||
@ -35,116 +45,29 @@ class TestReadSyncToken:
|
|||||||
assert _workout_sync.read_sync_token() == "abc123"
|
assert _workout_sync.read_sync_token() == "abc123"
|
||||||
|
|
||||||
|
|
||||||
class TestLatestPayload:
|
class TestIsSessionPayload:
|
||||||
def test_returns_none_for_an_empty_log(self) -> None:
|
"""Sessions are identified by SHAPE (an ``exercises`` list), not ``kind``."""
|
||||||
assert _workout_sync._latest_payload("{}") is None
|
|
||||||
|
|
||||||
def test_returns_the_only_records_payload(self) -> None:
|
def test_true_for_a_dict_with_an_exercises_list(self) -> None:
|
||||||
log = {"a": _record_json({"succeeded": True})}
|
assert _workout_sync._is_session_payload(_session_payload()) is True
|
||||||
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
|
|
||||||
|
|
||||||
def test_returns_the_payload_with_the_greatest_clock(self) -> None:
|
def test_true_even_with_an_empty_exercises_list(self) -> None:
|
||||||
log = {
|
assert _workout_sync._is_session_payload({"exercises": []}) is True
|
||||||
"old": _record_json({"succeeded": False}, wall_time_ms=100),
|
|
||||||
"new": _record_json({"succeeded": True}, wall_time_ms=200),
|
|
||||||
}
|
|
||||||
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
|
|
||||||
|
|
||||||
def test_skips_records_with_no_payload_field(self) -> None:
|
def test_false_for_a_manual_workout(self) -> None:
|
||||||
no_payload = Record(
|
assert _workout_sync._is_session_payload(_manual_payload()) is False
|
||||||
id="a",
|
|
||||||
fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))},
|
|
||||||
)
|
|
||||||
log = {"a": no_payload.to_dict()}
|
|
||||||
assert _workout_sync._latest_payload(json.dumps(log)) is None
|
|
||||||
|
|
||||||
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
|
def test_false_for_verified_records_sharing_the_same_log(self) -> None:
|
||||||
with pytest.raises(TypeError, match="not a JSON object"):
|
"""``*_verified`` records are non-manual but have no ``exercises``."""
|
||||||
_workout_sync._latest_payload(json.dumps([1, 2, 3]))
|
assert _workout_sync._is_session_payload({"kind": "runnerup_verified"}) is False
|
||||||
|
assert _workout_sync._is_session_payload({"kind": "phone_verified"}) is False
|
||||||
|
|
||||||
def test_raises_type_error_when_payload_field_is_not_an_object(self) -> None:
|
def test_false_when_exercises_is_not_a_list(self) -> None:
|
||||||
log = {"a": _record_json("not-a-dict")}
|
assert _workout_sync._is_session_payload({"exercises": "Squat"}) is False
|
||||||
with pytest.raises(TypeError, match="payload field is not a JSON object"):
|
|
||||||
_workout_sync._latest_payload(json.dumps(log))
|
|
||||||
|
|
||||||
def test_raises_key_error_for_a_malformed_record(self) -> None:
|
def test_false_for_a_non_dict_payload(self) -> None:
|
||||||
log = {"a": {"id": "a"}} # missing "fields"
|
assert _workout_sync._is_session_payload("not-a-dict") is False
|
||||||
with pytest.raises(KeyError):
|
assert _workout_sync._is_session_payload(None) is False
|
||||||
_workout_sync._latest_payload(json.dumps(log))
|
|
||||||
|
|
||||||
def test_raises_value_error_for_invalid_json(self) -> None:
|
|
||||||
with pytest.raises(ValueError, match="Expecting property name"):
|
|
||||||
_workout_sync._latest_payload("{not valid json")
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_client(*, get_file_text: object) -> MagicMock:
|
|
||||||
client = MagicMock()
|
|
||||||
if isinstance(get_file_text, Exception):
|
|
||||||
client.get_file_text.side_effect = get_file_text
|
|
||||||
else:
|
|
||||||
client.get_file_text.return_value = get_file_text
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
class TestPullSyncedWorkout:
|
|
||||||
def test_returns_none_none_when_no_token_is_configured(self) -> None:
|
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient") as client_cls:
|
|
||||||
assert _workout_sync.pull_synced_workout() == (None, None)
|
|
||||||
client_cls.assert_not_called()
|
|
||||||
|
|
||||||
def test_returns_the_error_message_on_a_sync_error(self) -> None:
|
|
||||||
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
||||||
client = _mock_client(get_file_text=GitHubSyncError("offline"))
|
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
||||||
assert _workout_sync.pull_synced_workout() == (None, "offline")
|
|
||||||
|
|
||||||
def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None:
|
|
||||||
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
||||||
client = _mock_client(get_file_text=None)
|
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
||||||
assert _workout_sync.pull_synced_workout() == (None, None)
|
|
||||||
|
|
||||||
def test_returns_the_payload_on_success(self) -> None:
|
|
||||||
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
||||||
log = json.dumps({"a": _record_json({"succeeded": True})})
|
|
||||||
client = _mock_client(get_file_text=log)
|
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
||||||
assert _workout_sync.pull_synced_workout() == ({"succeeded": True}, None)
|
|
||||||
|
|
||||||
def test_returns_a_corrupt_data_message_on_malformed_sync_data(self) -> None:
|
|
||||||
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
||||||
client = _mock_client(get_file_text="{not valid json")
|
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
||||||
data, error = _workout_sync.pull_synced_workout()
|
|
||||||
assert data is None
|
|
||||||
assert error is not None
|
|
||||||
assert "corrupt sync data" in error
|
|
||||||
|
|
||||||
|
|
||||||
def _manual_payload(**extra: object) -> dict:
|
|
||||||
payload = {"kind": "manual_workout", "date": "2026-07-13"}
|
|
||||||
payload.update(extra)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _manual_record_dict(
|
|
||||||
record_id: str, payload: dict, *, wall_time_ms: int = 1000
|
|
||||||
) -> dict:
|
|
||||||
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
|
|
||||||
return Record(id=record_id, fields={"payload": (payload, hlc)}).to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
class TestLatestPayloadSkipsManual:
|
|
||||||
def test_manual_only_log_yields_no_session(self) -> None:
|
|
||||||
log = {"m": _manual_record_dict("manual:1", _manual_payload())}
|
|
||||||
assert _workout_sync._latest_payload(json.dumps(log)) is None
|
|
||||||
|
|
||||||
def test_returns_session_even_when_a_later_manual_exists(self) -> None:
|
|
||||||
log = {
|
|
||||||
"s": _record_json({"succeeded": True}, wall_time_ms=100),
|
|
||||||
"m": _manual_record_dict("manual:1", _manual_payload(), wall_time_ms=999),
|
|
||||||
}
|
|
||||||
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsManualPayload:
|
class TestIsManualPayload:
|
||||||
@ -152,14 +75,46 @@ class TestIsManualPayload:
|
|||||||
assert _workout_sync._is_manual_payload({"kind": "manual_workout"}) is True
|
assert _workout_sync._is_manual_payload({"kind": "manual_workout"}) is True
|
||||||
|
|
||||||
def test_false_for_session_or_non_dict(self) -> None:
|
def test_false_for_session_or_non_dict(self) -> None:
|
||||||
assert _workout_sync._is_manual_payload({"succeeded": True}) is False
|
assert _workout_sync._is_manual_payload(_session_payload()) is False
|
||||||
assert _workout_sync._is_manual_payload("not-a-dict") is False
|
assert _workout_sync._is_manual_payload("not-a-dict") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionRecords:
|
||||||
|
def test_returns_only_session_records(self) -> None:
|
||||||
|
log = {
|
||||||
|
"s": _session_record_dict("session:1", _session_payload()),
|
||||||
|
"m": _manual_record_dict("manual:1", _manual_payload()),
|
||||||
|
"v": _manual_record_dict("verified:1", {"kind": "runnerup_verified"}),
|
||||||
|
}
|
||||||
|
result = _workout_sync._session_records(json.dumps(log))
|
||||||
|
assert list(result) == ["session:1"]
|
||||||
|
|
||||||
|
def test_skips_records_without_a_payload_field(self) -> None:
|
||||||
|
no_payload = Record(
|
||||||
|
id="a",
|
||||||
|
fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))},
|
||||||
|
)
|
||||||
|
log = {"a": no_payload.to_dict()}
|
||||||
|
assert _workout_sync._session_records(json.dumps(log)) == {}
|
||||||
|
|
||||||
|
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
|
||||||
|
with pytest.raises(TypeError, match="not a JSON object"):
|
||||||
|
_workout_sync._session_records(json.dumps([1, 2, 3]))
|
||||||
|
|
||||||
|
def test_raises_value_error_for_invalid_json(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="Expecting property name"):
|
||||||
|
_workout_sync._session_records("{not valid json")
|
||||||
|
|
||||||
|
def test_raises_key_error_for_a_malformed_record(self) -> None:
|
||||||
|
log = {"a": {"id": "a"}} # missing "fields"
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
_workout_sync._session_records(json.dumps(log))
|
||||||
|
|
||||||
|
|
||||||
class TestManualRecords:
|
class TestManualRecords:
|
||||||
def test_returns_only_manual_records(self) -> None:
|
def test_returns_only_manual_records(self) -> None:
|
||||||
log = {
|
log = {
|
||||||
"s": _record_json({"succeeded": True}),
|
"s": _record_json(_session_payload()),
|
||||||
"m": _manual_record_dict("manual:1", _manual_payload()),
|
"m": _manual_record_dict("manual:1", _manual_payload()),
|
||||||
}
|
}
|
||||||
result = _workout_sync._manual_records(json.dumps(log))
|
result = _workout_sync._manual_records(json.dumps(log))
|
||||||
@ -178,166 +133,146 @@ class TestManualRecords:
|
|||||||
_workout_sync._manual_records(json.dumps([1, 2, 3]))
|
_workout_sync._manual_records(json.dumps([1, 2, 3]))
|
||||||
|
|
||||||
|
|
||||||
def _multi_device_client(device_logs: dict[str, object]) -> MagicMock:
|
class TestMergeDeviceRecords:
|
||||||
client = MagicMock()
|
"""The walk over EVERY ``devices/<id>/log.json``, not one hardcoded path."""
|
||||||
client.list_directory.return_value = list(device_logs)
|
|
||||||
|
|
||||||
def _get(path: str) -> object:
|
def test_merges_records_across_every_device(self) -> None:
|
||||||
device = path.split("/")[-2]
|
|
||||||
value = device_logs[device]
|
|
||||||
if isinstance(value, Exception):
|
|
||||||
raise value
|
|
||||||
return value
|
|
||||||
|
|
||||||
client.get_file_text.side_effect = _get
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
class TestPullAllManualRecords:
|
|
||||||
def test_returns_empty_when_no_token(self) -> None:
|
|
||||||
assert _workout_sync.pull_all_manual_records() == []
|
|
||||||
|
|
||||||
def test_returns_empty_when_listing_fails(self) -> None:
|
|
||||||
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
||||||
client = MagicMock()
|
|
||||||
client.list_directory.side_effect = GitHubSyncError("offline")
|
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
||||||
assert _workout_sync.pull_all_manual_records() == []
|
|
||||||
|
|
||||||
def test_merges_manual_records_across_devices(self) -> None:
|
|
||||||
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
||||||
client = _multi_device_client(
|
client = _multi_device_client(
|
||||||
{
|
{
|
||||||
"phone": json.dumps(
|
"phone": json.dumps(
|
||||||
{"a": _manual_record_dict("manual:a", _manual_payload())}
|
{"a": _session_record_dict("session:a", _session_payload())}
|
||||||
),
|
),
|
||||||
"pc": json.dumps(
|
"pc": json.dumps(
|
||||||
{"b": _manual_record_dict("manual:b", _manual_payload())}
|
{"b": _session_record_dict("session:b", _session_payload())}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
merged = _workout_sync._merge_device_records(
|
||||||
result = _workout_sync.pull_all_manual_records()
|
client, _workout_sync._session_records, "sessions"
|
||||||
assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"]
|
)
|
||||||
|
assert sorted(merged) == ["session:a", "session:b"]
|
||||||
|
|
||||||
def test_skips_missing_and_corrupt_device_logs(self) -> None:
|
def test_dedups_the_same_id_keeping_the_highest_clock(self) -> None:
|
||||||
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
||||||
client = _multi_device_client(
|
client = _multi_device_client(
|
||||||
{
|
{
|
||||||
"phone": json.dumps(
|
"phone": json.dumps(
|
||||||
{"a": _manual_record_dict("manual:a", _manual_payload())}
|
{
|
||||||
|
"a": _session_record_dict(
|
||||||
|
"session:a",
|
||||||
|
_session_payload(note="OLD"),
|
||||||
|
wall_time_ms=100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"pc": json.dumps(
|
||||||
|
{
|
||||||
|
"a": _session_record_dict(
|
||||||
|
"session:a",
|
||||||
|
_session_payload(note="NEW"),
|
||||||
|
wall_time_ms=200,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
merged = _workout_sync._merge_device_records(
|
||||||
|
client, _workout_sync._session_records, "sessions"
|
||||||
|
)
|
||||||
|
assert len(merged) == 1
|
||||||
|
assert merged["session:a"][0]["note"] == "NEW"
|
||||||
|
|
||||||
|
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"phone": json.dumps(
|
||||||
|
{
|
||||||
|
"a": _session_record_dict(
|
||||||
|
"session:a",
|
||||||
|
_session_payload(note="NEW"),
|
||||||
|
wall_time_ms=200,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"pc": json.dumps(
|
||||||
|
{
|
||||||
|
"a": _session_record_dict(
|
||||||
|
"session:a",
|
||||||
|
_session_payload(note="OLD"),
|
||||||
|
wall_time_ms=100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
merged = _workout_sync._merge_device_records(
|
||||||
|
client, _workout_sync._session_records, "sessions"
|
||||||
|
)
|
||||||
|
assert merged["session:a"][0]["note"] == "NEW"
|
||||||
|
|
||||||
|
def test_skips_a_corrupt_device_log(self) -> None:
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"good": json.dumps(
|
||||||
|
{"a": _session_record_dict("session:a", _session_payload())}
|
||||||
),
|
),
|
||||||
"gone": GitHubSyncError("404"),
|
|
||||||
"empty": None,
|
|
||||||
"corrupt": "{not json",
|
"corrupt": "{not json",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
merged = _workout_sync._merge_device_records(
|
||||||
result = _workout_sync.pull_all_manual_records()
|
client, _workout_sync._session_records, "sessions"
|
||||||
assert [rid for rid, _ in result] == ["manual:a"]
|
)
|
||||||
|
assert list(merged) == ["session:a"]
|
||||||
|
|
||||||
def test_dedups_same_id_keeping_highest_clock(self) -> None:
|
def test_skips_an_unreachable_device(self) -> None:
|
||||||
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
||||||
client = _multi_device_client(
|
client = _multi_device_client(
|
||||||
{
|
{
|
||||||
"phone": json.dumps(
|
"good": json.dumps(
|
||||||
{
|
{"a": _session_record_dict("session:a", _session_payload())}
|
||||||
"a": _manual_record_dict(
|
|
||||||
"manual:a",
|
|
||||||
_manual_payload(cost="OLD"),
|
|
||||||
wall_time_ms=100,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"pc": json.dumps(
|
|
||||||
{
|
|
||||||
"a": _manual_record_dict(
|
|
||||||
"manual:a",
|
|
||||||
_manual_payload(cost="NEW"),
|
|
||||||
wall_time_ms=200,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
|
"gone": GitHubSyncError("404"),
|
||||||
|
"empty": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
merged = _workout_sync._merge_device_records(
|
||||||
result = _workout_sync.pull_all_manual_records()
|
client, _workout_sync._session_records, "sessions"
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0][1]["cost"] == "NEW"
|
|
||||||
|
|
||||||
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
|
|
||||||
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
||||||
client = _multi_device_client(
|
|
||||||
{
|
|
||||||
"phone": json.dumps(
|
|
||||||
{
|
|
||||||
"a": _manual_record_dict(
|
|
||||||
"manual:a",
|
|
||||||
_manual_payload(cost="NEW"),
|
|
||||||
wall_time_ms=200,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"pc": json.dumps(
|
|
||||||
{
|
|
||||||
"a": _manual_record_dict(
|
|
||||||
"manual:a",
|
|
||||||
_manual_payload(cost="OLD"),
|
|
||||||
wall_time_ms=100,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
assert list(merged) == ["session:a"]
|
||||||
result = _workout_sync.pull_all_manual_records()
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0][1]["cost"] == "NEW"
|
|
||||||
|
|
||||||
|
def test_returns_empty_on_a_listing_failure_when_not_strict(self) -> None:
|
||||||
class TestRemoteClient:
|
client = MagicMock()
|
||||||
"""Which backend the workout pull reads from during the cutover."""
|
client.list_directory.side_effect = GitHubSyncError("offline")
|
||||||
|
merged = _workout_sync._merge_device_records(
|
||||||
def test_stays_on_github_without_firebase_config(
|
client, _workout_sync._session_records, "sessions"
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""An unconfigured machine must not reach the network at all."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
|
|
||||||
)
|
)
|
||||||
github = object()
|
assert merged == {}
|
||||||
|
|
||||||
assert _workout_sync.remote_client(github) is github
|
def test_reraises_on_a_listing_failure_when_strict(self) -> None:
|
||||||
|
"""Callers separating "nothing to sync" from "sync broke" need the raise."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.list_directory.side_effect = GitHubSyncError("offline")
|
||||||
|
with pytest.raises(GitHubSyncError, match="offline"):
|
||||||
|
_workout_sync._merge_device_records(
|
||||||
|
client, _workout_sync._session_records, "sessions", strict=True
|
||||||
|
)
|
||||||
|
|
||||||
def test_mirrors_to_github_when_configured(
|
def test_warns_about_a_listing_failure(
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
self, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Configured: reads union both, so either backend's workout counts."""
|
"""A total pull failure must never be silent."""
|
||||||
config = tmp_path / "firebase.json"
|
client = MagicMock()
|
||||||
config.write_text("{}", encoding="utf-8")
|
client.list_directory.side_effect = GitHubSyncError("offline")
|
||||||
monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config)
|
with caplog.at_level("WARNING"):
|
||||||
monkeypatch.setattr(
|
_workout_sync._merge_device_records(
|
||||||
_workout_sync,
|
client, _workout_sync._session_records, "sessions"
|
||||||
"mirror_client_for",
|
)
|
||||||
lambda _app, client: ("mirror", client),
|
assert "offline" in caplog.text
|
||||||
)
|
|
||||||
github = object()
|
|
||||||
|
|
||||||
assert _workout_sync.remote_client(github) == ("mirror", github)
|
def test_warns_about_a_skipped_device(
|
||||||
|
self, caplog: pytest.LogCaptureFixture
|
||||||
def test_falls_back_when_firebase_is_unusable(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A broken Firebase must degrade to GitHub, never fail the pull."""
|
client = _multi_device_client({"gone": GitHubSyncError("404")})
|
||||||
config = tmp_path / "firebase.json"
|
with caplog.at_level("WARNING"):
|
||||||
config.write_text("{}", encoding="utf-8")
|
_workout_sync._merge_device_records(
|
||||||
monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config)
|
client, _workout_sync._session_records, "sessions"
|
||||||
|
)
|
||||||
def _boom(*_args: object, **_kwargs: object) -> None:
|
assert "SKIPPING that device" in caplog.text
|
||||||
message = "no password"
|
|
||||||
raise ConfigError(message)
|
|
||||||
|
|
||||||
monkeypatch.setattr(_workout_sync, "mirror_client_for", _boom)
|
|
||||||
github = object()
|
|
||||||
|
|
||||||
assert _workout_sync.remote_client(github) is github
|
|
||||||
|
|||||||
400
screen_locker/tests/test_workout_sync_part2.py
Normal file
400
screen_locker/tests/test_workout_sync_part2.py
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
"""Backend selection and the two public pulls, split from ``test_workout_sync``.
|
||||||
|
|
||||||
|
``sync_client`` decides WHICH backend is read; ``pull_synced_workout`` and
|
||||||
|
``pull_all_manual_records`` are what the locker actually calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from crdt_sync import (
|
||||||
|
ConfigError,
|
||||||
|
FirebaseAuthError,
|
||||||
|
FirebaseSyncError,
|
||||||
|
GitHubSyncError,
|
||||||
|
)
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from screen_locker import _workout_sync
|
||||||
|
from screen_locker.tests._workout_sync_fixtures import (
|
||||||
|
_firebase_config,
|
||||||
|
_manual_payload,
|
||||||
|
_manual_record_dict,
|
||||||
|
_multi_device_client,
|
||||||
|
_session_payload,
|
||||||
|
_session_record_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncClient:
|
||||||
|
"""Every configuration of (GitHub token, Firebase config), plus failure."""
|
||||||
|
|
||||||
|
def test_returns_none_when_nothing_is_configured(self) -> None:
|
||||||
|
assert _workout_sync.sync_client() is None
|
||||||
|
|
||||||
|
def test_warns_when_nothing_is_configured(
|
||||||
|
self, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
_workout_sync.sync_client()
|
||||||
|
assert "sync is OFF" in caplog.text
|
||||||
|
|
||||||
|
def test_uses_github_alone_when_only_the_token_exists(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
github = MagicMock()
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=github):
|
||||||
|
assert _workout_sync.sync_client() is github
|
||||||
|
|
||||||
|
def test_mirrors_when_both_the_token_and_firebase_exist(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
_firebase_config(_workout_sync, tmp_path, monkeypatch)
|
||||||
|
github = MagicMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_workout_sync, "mirror_client_for", lambda _app, client: ("mirror", client)
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=github):
|
||||||
|
assert _workout_sync.sync_client() == ("mirror", github)
|
||||||
|
|
||||||
|
def test_uses_firebase_alone_when_only_the_config_exists(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""A Firebase-only machine must still sync -- no PAT required."""
|
||||||
|
_firebase_config(_workout_sync, tmp_path, monkeypatch)
|
||||||
|
firebase = MagicMock()
|
||||||
|
monkeypatch.setattr(_workout_sync, "firebase_client_for", lambda _app: firebase)
|
||||||
|
assert _workout_sync.sync_client() is firebase
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
ConfigError("no password"),
|
||||||
|
FirebaseAuthError("bad credentials"),
|
||||||
|
FirebaseSyncError("backend down"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_returns_none_when_firebase_only_is_unusable(
|
||||||
|
self, error: Exception, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""No PAT to fall back to: report it loudly, do not raise."""
|
||||||
|
_firebase_config(_workout_sync, tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
def _boom(*_args: object, **_kwargs: object) -> None:
|
||||||
|
raise error
|
||||||
|
|
||||||
|
monkeypatch.setattr(_workout_sync, "firebase_client_for", _boom)
|
||||||
|
assert _workout_sync.sync_client() is None
|
||||||
|
|
||||||
|
def test_warns_when_firebase_only_is_unusable(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
_firebase_config(_workout_sync, tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
def _boom(*_args: object, **_kwargs: object) -> None:
|
||||||
|
message = "backend down"
|
||||||
|
raise FirebaseSyncError(message)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_workout_sync, "firebase_client_for", _boom)
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
_workout_sync.sync_client()
|
||||||
|
assert "backend down" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestPullSyncedWorkout:
|
||||||
|
def test_returns_none_none_when_no_token_is_configured(self) -> None:
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient") as client_cls:
|
||||||
|
assert _workout_sync.pull_synced_workout() == (None, None)
|
||||||
|
client_cls.assert_not_called()
|
||||||
|
|
||||||
|
def test_returns_the_error_message_on_a_sync_error(self) -> None:
|
||||||
|
"""A failed device LISTING is a real error, not "nothing to sync"."""
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = MagicMock()
|
||||||
|
client.list_directory.side_effect = GitHubSyncError("offline")
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
assert _workout_sync.pull_synced_workout() == (None, "offline")
|
||||||
|
|
||||||
|
def test_does_not_propagate_a_firebase_sync_error(self) -> None:
|
||||||
|
"""Regression: FirebaseSyncError is a SIBLING of GitHubSyncError.
|
||||||
|
|
||||||
|
Catching only ``GitHubSyncError`` let a Firebase failure escape and
|
||||||
|
crash the locker with status=1/FAILURE. It must come back as an error
|
||||||
|
tuple like any other backend failure.
|
||||||
|
"""
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = MagicMock()
|
||||||
|
client.list_directory.side_effect = FirebaseSyncError("firebase exploded")
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
data, error = _workout_sync.pull_synced_workout()
|
||||||
|
assert data is None
|
||||||
|
assert error == "firebase exploded"
|
||||||
|
|
||||||
|
def test_does_not_propagate_a_firebase_error_on_a_firebase_only_machine(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""The crash's real shape: no PAT, Firebase primary, backend throws.
|
||||||
|
|
||||||
|
The configuration the locker actually died in, pinned in its own right
|
||||||
|
so a future GitHub-only narrowing of the handler cannot pass.
|
||||||
|
"""
|
||||||
|
_firebase_config(_workout_sync, tmp_path, monkeypatch)
|
||||||
|
client = MagicMock()
|
||||||
|
client.list_directory.side_effect = FirebaseSyncError("firebase exploded")
|
||||||
|
monkeypatch.setattr(_workout_sync, "firebase_client_for", lambda _app: client)
|
||||||
|
data, error = _workout_sync.pull_synced_workout()
|
||||||
|
assert data is None
|
||||||
|
assert error == "firebase exploded"
|
||||||
|
|
||||||
|
def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = _multi_device_client({"phone": None})
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
assert _workout_sync.pull_synced_workout() == (None, None)
|
||||||
|
|
||||||
|
def test_returns_none_none_when_there_are_no_devices_at_all(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = _multi_device_client({})
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
assert _workout_sync.pull_synced_workout() == (None, None)
|
||||||
|
|
||||||
|
def test_returns_the_payload_on_success(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
payload = _session_payload()
|
||||||
|
client = _multi_device_client(
|
||||||
|
{"phone": json.dumps({"a": _session_record_dict("session:a", payload)})}
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
assert _workout_sync.pull_synced_workout() == (payload, None)
|
||||||
|
|
||||||
|
def test_finds_a_session_in_a_uuid_named_device_directory(self) -> None:
|
||||||
|
"""The actual bug: the phone's dir is a per-install uuid, not "phone".
|
||||||
|
|
||||||
|
Reading a single hardcoded ``devices/phone/log.json`` returned a stale
|
||||||
|
session forever while newer ones sat unread one directory over.
|
||||||
|
"""
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
fresh = _session_payload(note="FROM-UUID-DIR")
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"pc": json.dumps(
|
||||||
|
{
|
||||||
|
"old": _session_record_dict(
|
||||||
|
"session:old",
|
||||||
|
_session_payload(note="STALE"),
|
||||||
|
wall_time_ms=100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"0f1d2c3b-4a59-4c6d-8e7f-a0b1c2d3e4f5": json.dumps(
|
||||||
|
{
|
||||||
|
"new": _session_record_dict(
|
||||||
|
"session:new", fresh, wall_time_ms=200
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
assert _workout_sync.pull_synced_workout() == (fresh, None)
|
||||||
|
|
||||||
|
def test_returns_the_highest_clock_session_across_devices(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
newest = _session_payload(note="NEWEST")
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"phone-a": json.dumps(
|
||||||
|
{
|
||||||
|
"a": _session_record_dict(
|
||||||
|
"session:a",
|
||||||
|
_session_payload(note="OLDER"),
|
||||||
|
wall_time_ms=100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"phone-b": json.dumps(
|
||||||
|
{"b": _session_record_dict("session:b", newest, wall_time_ms=300)}
|
||||||
|
),
|
||||||
|
"phone-c": json.dumps(
|
||||||
|
{
|
||||||
|
"c": _session_record_dict(
|
||||||
|
"session:c",
|
||||||
|
_session_payload(note="MIDDLE"),
|
||||||
|
wall_time_ms=200,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
data, error = _workout_sync.pull_synced_workout()
|
||||||
|
assert error is None
|
||||||
|
assert data == newest
|
||||||
|
|
||||||
|
def test_ignores_non_session_records_in_the_same_logs(self) -> None:
|
||||||
|
"""``phone_verified`` / ``runnerup_verified`` share these device logs.
|
||||||
|
|
||||||
|
They are non-manual but have no ``exercises``, so "not manual" alone
|
||||||
|
would hand back a record with none of the fields a caller expects --
|
||||||
|
even though here they carry the HIGHEST clocks in the log.
|
||||||
|
"""
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
session = _session_payload(note="REAL-SESSION")
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"phone": json.dumps(
|
||||||
|
{
|
||||||
|
"s": _session_record_dict(
|
||||||
|
"session:a", session, wall_time_ms=100
|
||||||
|
),
|
||||||
|
"v": _session_record_dict(
|
||||||
|
"verified:1",
|
||||||
|
{"kind": "phone_verified", "date": "2026-08-10"},
|
||||||
|
wall_time_ms=900,
|
||||||
|
),
|
||||||
|
"r": _session_record_dict(
|
||||||
|
"verified:2",
|
||||||
|
{"kind": "runnerup_verified", "date": "2026-08-10"},
|
||||||
|
wall_time_ms=950,
|
||||||
|
),
|
||||||
|
"m": _manual_record_dict(
|
||||||
|
"manual:1", _manual_payload(), wall_time_ms=999
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
data, error = _workout_sync.pull_synced_workout()
|
||||||
|
assert error is None
|
||||||
|
assert data == session
|
||||||
|
|
||||||
|
def test_skips_a_corrupt_device_log_instead_of_failing(self) -> None:
|
||||||
|
"""A corrupt log costs that device's sessions, not the whole pull."""
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
payload = _session_payload()
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"corrupt": "{not valid json",
|
||||||
|
"phone": json.dumps({"a": _session_record_dict("session:a", payload)}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
assert _workout_sync.pull_synced_workout() == (payload, None)
|
||||||
|
|
||||||
|
def test_returns_none_none_when_every_device_log_is_corrupt(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = _multi_device_client({"corrupt": "{not valid json"})
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
assert _workout_sync.pull_synced_workout() == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPullAllManualRecords:
|
||||||
|
def test_returns_empty_when_no_token(self) -> None:
|
||||||
|
assert _workout_sync.pull_all_manual_records() == []
|
||||||
|
|
||||||
|
def test_returns_empty_when_listing_fails(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = MagicMock()
|
||||||
|
client.list_directory.side_effect = GitHubSyncError("offline")
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
assert _workout_sync.pull_all_manual_records() == []
|
||||||
|
|
||||||
|
def test_merges_manual_records_across_devices(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"phone": json.dumps(
|
||||||
|
{"a": _manual_record_dict("manual:a", _manual_payload())}
|
||||||
|
),
|
||||||
|
"pc": json.dumps(
|
||||||
|
{"b": _manual_record_dict("manual:b", _manual_payload())}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
result = _workout_sync.pull_all_manual_records()
|
||||||
|
assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"]
|
||||||
|
|
||||||
|
def test_skips_missing_and_corrupt_device_logs(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"phone": json.dumps(
|
||||||
|
{"a": _manual_record_dict("manual:a", _manual_payload())}
|
||||||
|
),
|
||||||
|
"gone": GitHubSyncError("404"),
|
||||||
|
"empty": None,
|
||||||
|
"corrupt": "{not json",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
result = _workout_sync.pull_all_manual_records()
|
||||||
|
assert [rid for rid, _ in result] == ["manual:a"]
|
||||||
|
|
||||||
|
def test_dedups_same_id_keeping_highest_clock(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"phone": json.dumps(
|
||||||
|
{
|
||||||
|
"a": _manual_record_dict(
|
||||||
|
"manual:a",
|
||||||
|
_manual_payload(cost="OLD"),
|
||||||
|
wall_time_ms=100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"pc": json.dumps(
|
||||||
|
{
|
||||||
|
"a": _manual_record_dict(
|
||||||
|
"manual:a",
|
||||||
|
_manual_payload(cost="NEW"),
|
||||||
|
wall_time_ms=200,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
result = _workout_sync.pull_all_manual_records()
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0][1]["cost"] == "NEW"
|
||||||
|
|
||||||
|
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
|
||||||
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
||||||
|
client = _multi_device_client(
|
||||||
|
{
|
||||||
|
"phone": json.dumps(
|
||||||
|
{
|
||||||
|
"a": _manual_record_dict(
|
||||||
|
"manual:a",
|
||||||
|
_manual_payload(cost="NEW"),
|
||||||
|
wall_time_ms=200,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"pc": json.dumps(
|
||||||
|
{
|
||||||
|
"a": _manual_record_dict(
|
||||||
|
"manual:a",
|
||||||
|
_manual_payload(cost="OLD"),
|
||||||
|
wall_time_ms=100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
||||||
|
result = _workout_sync.pull_all_manual_records()
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0][1]["cost"] == "NEW"
|
||||||
62
screen_locker/tests/test_workout_sync_part3.py
Normal file
62
screen_locker/tests/test_workout_sync_part3.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""``remote_client`` mirror selection, split from ``test_workout_sync_part2``.
|
||||||
|
|
||||||
|
Kept separate from the pulls so no workout-sync test file exceeds the repo's
|
||||||
|
400-line limit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from crdt_sync import ConfigError
|
||||||
|
|
||||||
|
from screen_locker import _workout_sync
|
||||||
|
from screen_locker.tests._workout_sync_fixtures import _firebase_config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteClient:
|
||||||
|
"""Which backend the workout pull reads from during the cutover."""
|
||||||
|
|
||||||
|
def test_stays_on_github_without_firebase_config(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""An unconfigured machine must not reach the network at all."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
|
||||||
|
)
|
||||||
|
github = object()
|
||||||
|
|
||||||
|
assert _workout_sync.remote_client(github) is github
|
||||||
|
|
||||||
|
def test_mirrors_to_github_when_configured(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Configured: reads union both, so either backend's workout counts."""
|
||||||
|
_firebase_config(_workout_sync, tmp_path, monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_workout_sync,
|
||||||
|
"mirror_client_for",
|
||||||
|
lambda _app, client: ("mirror", client),
|
||||||
|
)
|
||||||
|
github = object()
|
||||||
|
|
||||||
|
assert _workout_sync.remote_client(github) == ("mirror", github)
|
||||||
|
|
||||||
|
def test_falls_back_when_firebase_is_unusable(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""A broken Firebase must degrade to GitHub, never fail the pull."""
|
||||||
|
_firebase_config(_workout_sync, tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
def _boom(*_args: object, **_kwargs: object) -> None:
|
||||||
|
message = "no password"
|
||||||
|
raise ConfigError(message)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_workout_sync, "mirror_client_for", _boom)
|
||||||
|
github = object()
|
||||||
|
|
||||||
|
assert _workout_sync.remote_client(github) is github
|
||||||
@ -17,7 +17,20 @@ void main() async {
|
|||||||
// Before anything that stamps an Hlc or syncs: until this resolves, the
|
// Before anything that stamps an Hlc or syncs: until this resolves, the
|
||||||
// device id falls back to the pre-migration constant.
|
// device id falls back to the pre-migration constant.
|
||||||
await initSyncDeviceId();
|
await initSyncDeviceId();
|
||||||
await BackupService.instance.requestStoragePermission();
|
// Storage permission MUST be settled before the DB is opened: opening it
|
||||||
|
// seeds factory defaults for any missing exercise, and restoring afterwards
|
||||||
|
// is a no-op once rows exist. Losing the race means a reinstall silently
|
||||||
|
// comes up at defaults with months of progression gone -- which is what
|
||||||
|
// happened on 2026-08-05.
|
||||||
|
final storageGranted = await BackupService.instance
|
||||||
|
.requestStoragePermission();
|
||||||
|
if (!storageGranted) {
|
||||||
|
debugPrint(
|
||||||
|
'WorkoutApp: storage permission DENIED — the backup at $kBackupPath can '
|
||||||
|
'neither be read nor written. Progression cannot be restored on this '
|
||||||
|
'launch and will not be protected against the next reinstall.',
|
||||||
|
);
|
||||||
|
}
|
||||||
await StorageService.init();
|
await StorageService.init();
|
||||||
await StorageService.instance.restoreFromBackupIfNeeded();
|
await StorageService.instance.restoreFromBackupIfNeeded();
|
||||||
await HttpServerService.instance.start();
|
await HttpServerService.instance.start();
|
||||||
|
|||||||
@ -57,6 +57,12 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
|
// Put any of this device's own sessions that only exist remotely back into
|
||||||
|
// the local table FIRST, so they show up in this same load. A reinstall
|
||||||
|
// wipes local history while Firebase/GitHub keep it; without this the app
|
||||||
|
// silently shows less history than actually happened.
|
||||||
|
final remote = await _readSyncedPayloads();
|
||||||
|
await StorageService.instance.restoreSyncedSessions(remote);
|
||||||
final rows = await StorageService.instance.getWorkoutHistory(limit: 200);
|
final rows = await StorageService.instance.getWorkoutHistory(limit: 200);
|
||||||
final names = <String>[];
|
final names = <String>[];
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
@ -76,7 +82,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
state = await StorageService.instance.getExerciseState(_selected);
|
state = await StorageService.instance.getExerciseState(_selected);
|
||||||
// coverage:ignore-end
|
// coverage:ignore-end
|
||||||
}
|
}
|
||||||
final synced = await _loadSyncedWorkouts();
|
final synced = _syncedOnly(remote);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_rows = rows;
|
_rows = rows;
|
||||||
@ -88,20 +94,27 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every payload the sync backends hold, across all devices.
|
||||||
|
///
|
||||||
|
/// Read once per load and used twice: to restore this device's own missing
|
||||||
|
/// sessions into local history, and to list the PC-only records below them.
|
||||||
|
Future<List<Map<String, dynamic>>> _readSyncedPayloads() =>
|
||||||
|
WorkoutSyncService(
|
||||||
|
httpClient: widget.httpClient,
|
||||||
|
).readMergedWorkoutPayloads();
|
||||||
|
|
||||||
/// Workouts the PC published that this phone has no local record of.
|
/// Workouts the PC published that this phone has no local record of.
|
||||||
///
|
///
|
||||||
/// The PC pushes its whole `workout_log.json` (RunnerUp runs and manual
|
/// The PC pushes its whole `workout_log.json` (RunnerUp runs and manual
|
||||||
/// entries included), so pulling them here is what makes both devices show
|
/// entries included), so pulling them here is what makes both devices show
|
||||||
/// the SAME history. Sorted newest-first to match the local session list.
|
/// the SAME history. Sorted newest-first to match the local session list.
|
||||||
Future<List<Map<String, dynamic>>> _loadSyncedWorkouts() async {
|
/// StrongLifts sessions are deliberately excluded: they are restored into
|
||||||
final payloads = await WorkoutSyncService(
|
/// local history instead, so listing them here too would double them up.
|
||||||
httpClient: widget.httpClient,
|
List<Map<String, dynamic>> _syncedOnly(
|
||||||
).readMergedWorkoutPayloads();
|
List<Map<String, dynamic>> payloads,
|
||||||
final synced =
|
) =>
|
||||||
payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList()
|
payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList()
|
||||||
..sort((a, b) => '${b['date']}'.compareTo('${a['date']}'));
|
..sort((a, b) => '${b['date']}'.compareTo('${a['date']}'));
|
||||||
return synced;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pickExercise(String name) async {
|
Future<void> _pickExercise(String name) async {
|
||||||
ExerciseState? state;
|
ExerciseState? state;
|
||||||
|
|||||||
@ -59,10 +59,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final Map<String, int> _successThresholds = {};
|
final Map<String, int> _successThresholds = {};
|
||||||
final Map<String, int> _failThresholds = {};
|
final Map<String, int> _failThresholds = {};
|
||||||
final Map<String, double> _weights = {};
|
final Map<String, double> _weights = {};
|
||||||
|
final Map<String, int> _reps = {};
|
||||||
|
|
||||||
// Debounce weight saves to avoid resetting streaks on every tap.
|
// Debounce weight saves to avoid resetting streaks on every tap.
|
||||||
final Map<String, Timer> _weightTimers = {};
|
final Map<String, Timer> _weightTimers = {};
|
||||||
|
|
||||||
|
// Same debounce for reps, for the same reason.
|
||||||
|
final Map<String, Timer> _repsTimers = {};
|
||||||
|
|
||||||
final _tokenController = TextEditingController();
|
final _tokenController = TextEditingController();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
@ -154,6 +158,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
for (final t in _weightTimers.values) {
|
for (final t in _weightTimers.values) {
|
||||||
t.cancel();
|
t.cancel();
|
||||||
}
|
}
|
||||||
|
for (final t in _repsTimers.values) {
|
||||||
|
t.cancel();
|
||||||
|
}
|
||||||
_tokenController.dispose();
|
_tokenController.dispose();
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
@ -169,6 +176,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_successThresholds[s.name] = s.successThreshold;
|
_successThresholds[s.name] = s.successThreshold;
|
||||||
_failThresholds[s.name] = s.failThreshold;
|
_failThresholds[s.name] = s.failThreshold;
|
||||||
_weights[s.name] = s.weight;
|
_weights[s.name] = s.weight;
|
||||||
|
_reps[s.name] = s.reps;
|
||||||
}
|
}
|
||||||
_tokenController.text = syncSettings.token;
|
_tokenController.text = syncSettings.token;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
@ -299,6 +307,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onRepsChanged(String name, int value) {
|
||||||
|
setState(() => _reps[name] = value);
|
||||||
|
_repsTimers[name]?.cancel();
|
||||||
|
_repsTimers[name] = Timer(const Duration(milliseconds: 600), () {
|
||||||
|
unawaited(StorageService.instance.setExerciseReps(name, value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onThresholdChanged(String name, int success, int fail) async {
|
Future<void> _onThresholdChanged(String name, int success, int fail) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_successThresholds[name] = success;
|
_successThresholds[name] = success;
|
||||||
@ -404,6 +420,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
const _SectionHeader('TARGET REPS'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Override target reps per set. Resets streak counters. '
|
||||||
|
'Progression only ever raises reps, so this is the only way '
|
||||||
|
'to lower one.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: AppTextSize.caption,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
..._orderedNames.map((name) {
|
||||||
|
final r = _reps[name];
|
||||||
|
if (r == null) return const SizedBox.shrink();
|
||||||
|
return _RepsRow(
|
||||||
|
name: name,
|
||||||
|
reps: r,
|
||||||
|
onChanged: (v) => _onRepsChanged(name, v),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 20),
|
||||||
const _SectionHeader('PROGRESSION THRESHOLDS'),
|
const _SectionHeader('PROGRESSION THRESHOLDS'),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@ -559,6 +597,59 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RepsRow extends StatelessWidget {
|
||||||
|
const _RepsRow({
|
||||||
|
required this.name,
|
||||||
|
required this.reps,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final int reps;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: AppTextSize.label,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_StepperButton(
|
||||||
|
icon: Icons.remove,
|
||||||
|
onTap: () => onChanged((reps - 1).clamp(1, 999)),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 72,
|
||||||
|
child: Text(
|
||||||
|
'$reps reps',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: AppTextSize.label,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_StepperButton(
|
||||||
|
icon: Icons.add,
|
||||||
|
onTap: () => onChanged((reps + 1).clamp(1, 999)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _WeightRow extends StatelessWidget {
|
class _WeightRow extends StatelessWidget {
|
||||||
const _WeightRow({
|
const _WeightRow({
|
||||||
required this.name,
|
required this.name,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ library;
|
|||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
/// Default directory backups live in on external storage.
|
/// Default directory backups live in on external storage.
|
||||||
@ -87,9 +87,13 @@ class BackupService {
|
|||||||
|
|
||||||
/// Writes [data] to the backup path as pretty-printed JSON.
|
/// Writes [data] to the backup path as pretty-printed JSON.
|
||||||
///
|
///
|
||||||
/// Silently swallows errors (e.g. permission not granted, no external
|
/// Never throws — a failed backup must not crash a workout in progress — but
|
||||||
/// storage) so the caller never crashes.
|
/// it is NOT silent: a backup that quietly stops working is invisible until
|
||||||
Future<void> export(Map<String, dynamic> data) async {
|
/// the reinstall that needed it, which is exactly how the 2026-08-05 wipe
|
||||||
|
/// destroyed months of progression.
|
||||||
|
///
|
||||||
|
/// Returns true if the backup was written.
|
||||||
|
Future<bool> export(Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
final dir = Directory(_baseDir);
|
final dir = Directory(_baseDir);
|
||||||
if (!dir.existsSync()) {
|
if (!dir.existsSync()) {
|
||||||
@ -98,8 +102,14 @@ class BackupService {
|
|||||||
await File(
|
await File(
|
||||||
_backupPath,
|
_backupPath,
|
||||||
).writeAsString(const JsonEncoder.withIndent(' ').convert(data));
|
).writeAsString(const JsonEncoder.withIndent(' ').convert(data));
|
||||||
} on Exception {
|
return true;
|
||||||
// Backup is best-effort; never throw.
|
} on Exception catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'WorkoutApp: BACKUP FAILED to $_backupPath: $e — progression state is '
|
||||||
|
'now unprotected against an uninstall/reinstall. Check that storage '
|
||||||
|
'permission is granted.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,13 +117,29 @@ class BackupService {
|
|||||||
|
|
||||||
/// Returns the parsed backup JSON, or null if the file does not exist or
|
/// Returns the parsed backup JSON, or null if the file does not exist or
|
||||||
/// is unreadable.
|
/// is unreadable.
|
||||||
|
///
|
||||||
|
/// Distinguishes the two cases in the log: "no backup exists" is normal on a
|
||||||
|
/// genuinely first install, whereas "a backup exists but could not be read"
|
||||||
|
/// means a restore is about to fall through to defaults and silently lose
|
||||||
|
/// real data. The second must never pass unnoticed.
|
||||||
Future<Map<String, dynamic>?> readBackup() async {
|
Future<Map<String, dynamic>?> readBackup() async {
|
||||||
|
final f = File(_backupPath);
|
||||||
try {
|
try {
|
||||||
final f = File(_backupPath);
|
if (!f.existsSync()) {
|
||||||
if (!f.existsSync()) return null;
|
debugPrint(
|
||||||
|
'WorkoutApp: no backup at $_backupPath — nothing to restore. Normal '
|
||||||
|
'on a first install; on a REINSTALL it means the backup was lost.',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final raw = await f.readAsString();
|
final raw = await f.readAsString();
|
||||||
return jsonDecode(raw) as Map<String, dynamic>;
|
return jsonDecode(raw) as Map<String, dynamic>;
|
||||||
} on Exception {
|
} on Exception catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'WorkoutApp: BACKUP UNREADABLE at $_backupPath: $e — a backup EXISTS '
|
||||||
|
'but cannot be parsed, so restore will fall through to defaults and '
|
||||||
|
'lose progression state. Do not let the app overwrite it.',
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -316,6 +316,22 @@ class StorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the target reps for [name], resetting streaks.
|
||||||
|
///
|
||||||
|
/// Progression can only ever raise reps (by one, and only once an exercise
|
||||||
|
/// is at its max weight), so without this there is no way to lower a rep
|
||||||
|
/// target that a defaults re-seed set wrong -- the 2026-08-05 wipe dropped
|
||||||
|
/// Situp from 31 to the default 30 with no way back short of editing the DB.
|
||||||
|
Future<void> setExerciseReps(String name, int reps) async {
|
||||||
|
await _db.update(
|
||||||
|
'exercise_state',
|
||||||
|
{'reps': reps, 'success_streak': 0, 'fail_streak': 0},
|
||||||
|
where: 'name = ?',
|
||||||
|
whereArgs: [name],
|
||||||
|
);
|
||||||
|
unawaited(_backupNow());
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the working weight for [name], resetting streaks.
|
/// Sets the working weight for [name], resetting streaks.
|
||||||
Future<void> setExerciseWeight(String name, double weight) async {
|
Future<void> setExerciseWeight(String name, double weight) async {
|
||||||
await _db.update(
|
await _db.update(
|
||||||
@ -449,6 +465,76 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns up to [limit] rows from workout history, newest first.
|
/// Returns up to [limit] rows from workout history, newest first.
|
||||||
|
/// Inserts synced sessions this device has no local record of.
|
||||||
|
///
|
||||||
|
/// These are the user's OWN completed workouts, already in Firebase/GitHub;
|
||||||
|
/// a reinstall wipes the local `workout_history` table while the remote copy
|
||||||
|
/// survives, so the app ends up showing far less history than actually
|
||||||
|
/// happened. This puts the real records back rather than inventing anything.
|
||||||
|
///
|
||||||
|
/// Idempotent: a session is matched on (date, start_time) taken from the
|
||||||
|
/// payload, so repeated syncs never duplicate a row. Sessions the device
|
||||||
|
/// already has always win -- the local row is the one the progression
|
||||||
|
/// engine acted on.
|
||||||
|
///
|
||||||
|
/// Returns the number of sessions restored.
|
||||||
|
Future<int> restoreSyncedSessions(
|
||||||
|
List<Map<String, dynamic>> payloads,
|
||||||
|
) async {
|
||||||
|
if (payloads.isEmpty) return 0;
|
||||||
|
final existing = await _db.query('workout_history', columns: ['json']);
|
||||||
|
final seen = <String>{
|
||||||
|
for (final row in existing) _sessionKey(_decodeJson(row['json'])),
|
||||||
|
};
|
||||||
|
|
||||||
|
var restored = 0;
|
||||||
|
for (final payload in payloads) {
|
||||||
|
// Only real sessions: a payload with no exercises is a PC-side
|
||||||
|
// runnerup/manual record, which belongs in the synced list, not here.
|
||||||
|
if (payload['exercises'] is! List) continue;
|
||||||
|
final key = _sessionKey(payload);
|
||||||
|
if (key.isEmpty || seen.contains(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
await _db.insert('workout_history', {
|
||||||
|
'date': payload['date'] as String? ?? '',
|
||||||
|
'workout_type': payload['workout_type'] as String? ?? '',
|
||||||
|
'duration_seconds': (payload['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||||
|
'succeeded': (payload['succeeded'] as bool? ?? false) ? 1 : 0,
|
||||||
|
'json': jsonEncode(payload),
|
||||||
|
});
|
||||||
|
restored++;
|
||||||
|
}
|
||||||
|
if (restored > 0) {
|
||||||
|
debugPrint('WorkoutApp: restored $restored synced session(s) locally.');
|
||||||
|
unawaited(_backupNow());
|
||||||
|
}
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity of a session: its date plus start time, both from the payload.
|
||||||
|
///
|
||||||
|
/// `start_time` alone would be enough, but pairing it with the date keeps
|
||||||
|
/// the key readable in logs and survives a payload missing either field.
|
||||||
|
static String _sessionKey(Map<String, dynamic>? payload) {
|
||||||
|
if (payload == null) return '';
|
||||||
|
final start = payload['start_time'] as String? ?? '';
|
||||||
|
final date = payload['date'] as String? ?? '';
|
||||||
|
return start.isEmpty && date.isEmpty ? '' : '$date|$start';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic>? _decodeJson(Object? raw) {
|
||||||
|
if (raw is! String || raw.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
return decoded is Map<String, dynamic> ? decoded : null;
|
||||||
|
} on FormatException {
|
||||||
|
// A corrupt local row must not block restoring the rest; it simply
|
||||||
|
// cannot be matched against, so it is treated as "not seen".
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns up to [limit] past sessions, newest first.
|
||||||
Future<List<Map<String, dynamic>>> getWorkoutHistory({
|
Future<List<Map<String, dynamic>>> getWorkoutHistory({
|
||||||
int limit = 60,
|
int limit = 60,
|
||||||
}) async {
|
}) async {
|
||||||
@ -491,10 +577,34 @@ class StorageService {
|
|||||||
// ── Backup / restore ───────────────────────────────────────────────────────
|
// ── Backup / restore ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Exports all persistent data to external storage as a JSON snapshot.
|
/// Exports all persistent data to external storage as a JSON snapshot.
|
||||||
|
///
|
||||||
|
/// Refuses to overwrite a richer existing backup with a freshly-seeded,
|
||||||
|
/// history-less DB. That combination is never a real state worth saving: it
|
||||||
|
/// only happens when a restore failed (permission denied, unreadable file)
|
||||||
|
/// and the DB is sitting at [workoutA]/[workoutB] defaults. The old code
|
||||||
|
/// exported anyway on the first write after such a seed, replacing the only
|
||||||
|
/// off-device copy of months of progression with default rows — the step
|
||||||
|
/// that turned the 2026-08-05 reinstall from recoverable into permanent.
|
||||||
Future<void> _backupNow() async {
|
Future<void> _backupNow() async {
|
||||||
final exerciseRows = await _db.query('exercise_state');
|
final exerciseRows = await _db.query('exercise_state');
|
||||||
final historyRows = await _db.query('workout_history');
|
final historyRows = await _db.query('workout_history');
|
||||||
final settingsRows = await _db.query('settings');
|
final settingsRows = await _db.query('settings');
|
||||||
|
|
||||||
|
if (historyRows.isEmpty) {
|
||||||
|
final existing = await BackupService.instance.readBackup();
|
||||||
|
final existingHistory =
|
||||||
|
(existing?['workout_history'] as List?) ?? const [];
|
||||||
|
if (existingHistory.isNotEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'WorkoutApp: REFUSING to overwrite the backup — this DB has no '
|
||||||
|
'workout history but the backup holds ${existingHistory.length} '
|
||||||
|
'session(s). A restore almost certainly failed; keeping the backup '
|
||||||
|
'so the data stays recoverable.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await BackupService.instance.export({
|
await BackupService.instance.export({
|
||||||
'exercise_state': exerciseRows,
|
'exercise_state': exerciseRows,
|
||||||
'workout_history': historyRows,
|
'workout_history': historyRows,
|
||||||
@ -514,7 +624,16 @@ class StorageService {
|
|||||||
if (hasHistory > 0 || hasType != null) return; // DB has real data
|
if (hasHistory > 0 || hasType != null) return; // DB has real data
|
||||||
|
|
||||||
final backup = await BackupService.instance.readBackup();
|
final backup = await BackupService.instance.readBackup();
|
||||||
if (backup == null) return;
|
if (backup == null) {
|
||||||
|
// readBackup() has already said which case this is. Say what it MEANS:
|
||||||
|
// the DB is sitting at seeded defaults and that is now the live state.
|
||||||
|
debugPrint(
|
||||||
|
'WorkoutApp: no backup restored — the database is at factory defaults. '
|
||||||
|
'If this is a reinstall, progression state (weights, reps, streaks) '
|
||||||
|
'has been lost and must be re-entered.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _db.transaction((txn) async {
|
await _db.transaction((txn) async {
|
||||||
for (final row in (backup['exercise_state'] as List? ?? [])
|
for (final row in (backup['exercise_state'] as List? ?? [])
|
||||||
|
|||||||
@ -141,11 +141,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
ref: "crdt_sync_dart-v0.7.0"
|
ref: "crdt_sync_dart-v0.9.0"
|
||||||
resolved-ref: "6eef99d66132e12eb5056ae8e1780b34237fa013"
|
resolved-ref: f902bbe0aa6689adbe4ef0ef6bb298655f4e136a
|
||||||
url: "https://github.com/kuhyx/utils"
|
url: "https://github.com/kuhyx/utils"
|
||||||
source: git
|
source: git
|
||||||
version: "0.7.0"
|
version: "0.9.0"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -12,7 +12,7 @@ dependencies:
|
|||||||
crdt_sync:
|
crdt_sync:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: crdt_sync_dart-v0.7.0
|
ref: crdt_sync_dart-v0.9.0
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@ -134,14 +134,40 @@ void main() {
|
|||||||
expect(find.text('Settings'), findsOneWidget);
|
expect(find.text('Settings'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows WEIGHTS and PROGRESSION THRESHOLDS sections', (
|
testWidgets('shows WEIGHTS, TARGET REPS and PROGRESSION THRESHOLDS', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await _pump(tester, _wrap());
|
await _pump(tester, _wrap());
|
||||||
expect(find.text('WEIGHTS'), findsOneWidget);
|
expect(find.text('WEIGHTS'), findsOneWidget);
|
||||||
|
// TARGET REPS and the thresholds below it are off-screen at the test
|
||||||
|
// viewport height, so scroll them into view rather than asserting on
|
||||||
|
// whatever happens to be built.
|
||||||
|
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
|
||||||
|
expect(find.text('TARGET REPS'), findsOneWidget);
|
||||||
|
await tester.scrollUntilVisible(find.text('PROGRESSION THRESHOLDS'), 200);
|
||||||
expect(find.text('PROGRESSION THRESHOLDS'), findsOneWidget);
|
expect(find.text('PROGRESSION THRESHOLDS'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('increment reps button increases the target reps', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pump(tester, _wrap());
|
||||||
|
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// Situp defaults to 30 reps and is the only exercise at that value.
|
||||||
|
expect(find.text('30 reps'), findsOneWidget);
|
||||||
|
final plus = find.descendant(
|
||||||
|
of: find.ancestor(
|
||||||
|
of: find.text('30 reps'),
|
||||||
|
matching: find.byType(Row),
|
||||||
|
).first,
|
||||||
|
matching: find.byIcon(Icons.add),
|
||||||
|
);
|
||||||
|
await tester.tap(plus);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('31 reps'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('shows all exercise names from both workout plans', (
|
testWidgets('shows all exercise names from both workout plans', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@ -193,6 +219,10 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('threshold circles show values 1-5', (tester) async {
|
testWidgets('threshold circles show values 1-5', (tester) async {
|
||||||
await _pump(tester, _wrap());
|
await _pump(tester, _wrap());
|
||||||
|
// The threshold cards sit below WEIGHTS and TARGET REPS, past the test
|
||||||
|
// viewport's fold, so they are not built until scrolled to.
|
||||||
|
await tester.scrollUntilVisible(find.text('PROGRESSION THRESHOLDS'), 200);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
for (int i = 1; i <= 5; i++) {
|
for (int i = 1; i <= 5; i++) {
|
||||||
expect(find.text('$i'), findsWidgets);
|
expect(find.text('$i'), findsWidgets);
|
||||||
}
|
}
|
||||||
@ -582,6 +612,11 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('changing a success threshold persists it', (tester) async {
|
testWidgets('changing a success threshold persists it', (tester) async {
|
||||||
await _pump(tester, _wrap());
|
await _pump(tester, _wrap());
|
||||||
|
// Scroll to the section header first: the threshold rows below it are not
|
||||||
|
// built at all until then, so a `.first` finder for the label would throw
|
||||||
|
// "Bad state: No element" before it could match anything.
|
||||||
|
await tester.scrollUntilVisible(find.text('PROGRESSION THRESHOLDS'), 200);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
final label = find.text('↑ Increase after N successes').first;
|
final label = find.text('↑ Increase after N successes').first;
|
||||||
await tester.scrollUntilVisible(
|
await tester.scrollUntilVisible(
|
||||||
label,
|
label,
|
||||||
@ -630,6 +665,33 @@ void main() {
|
|||||||
expect(states2!.any((s) => s.failThreshold == 4), isTrue);
|
expect(states2!.any((s) => s.failThreshold == 4), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('reps change is debounced then written to storage', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pump(tester, _wrap());
|
||||||
|
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Situp defaults to 30 reps and is the only exercise at that value.
|
||||||
|
final minus = find.descendant(
|
||||||
|
of: find
|
||||||
|
.ancestor(of: find.text('30 reps'), matching: find.byType(Row))
|
||||||
|
.first,
|
||||||
|
matching: find.byIcon(Icons.remove),
|
||||||
|
);
|
||||||
|
// Same 600ms debounce as weights: run on the real loop so the timer fires.
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.tap(minus);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||||
|
});
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final after = (await tester.runAsync(
|
||||||
|
() => StorageService.instance.getExerciseState('Situp'),
|
||||||
|
))!.reps;
|
||||||
|
expect(after, 29, reason: 'the decrement must reach storage');
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('weight change is debounced then written to storage', (
|
testWidgets('weight change is debounced then written to storage', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@ -156,6 +157,25 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('setExerciseReps', () {
|
||||||
|
test('updates reps and resets streaks', () async {
|
||||||
|
// Progression can only ever RAISE reps, so this is the only way to
|
||||||
|
// correct a rep target a defaults re-seed set wrong.
|
||||||
|
const name = 'Situp';
|
||||||
|
await _svc.setExerciseReps(name, 31);
|
||||||
|
final state = await _svc.getExerciseState(name);
|
||||||
|
expect(state!.reps, 31);
|
||||||
|
expect(state.successStreak, 0);
|
||||||
|
expect(state.failStreak, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can lower a rep target', () async {
|
||||||
|
const name = 'Situp';
|
||||||
|
await _svc.setExerciseReps(name, 25);
|
||||||
|
expect((await _svc.getExerciseState(name))!.reps, 25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('setExerciseWeight', () {
|
group('setExerciseWeight', () {
|
||||||
test('updates weight and resets streaks', () async {
|
test('updates weight and resets streaks', () async {
|
||||||
final name = workoutA.first.name;
|
final name = workoutA.first.name;
|
||||||
@ -438,6 +458,169 @@ void main() {
|
|||||||
|
|
||||||
// ── Restore from backup ─────────────────────────────────────────────────────
|
// ── Restore from backup ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Regression guard for the 2026-08-05 data loss: a reinstall re-seeded the
|
||||||
|
// DB to factory defaults, and the first write afterwards exported those
|
||||||
|
// defaults over /sdcard/WorkoutTracker/backup.json -- destroying the only
|
||||||
|
// off-device copy of months of progression. The seed is survivable; the
|
||||||
|
// clobber is what made it permanent.
|
||||||
|
group('restoreSyncedSessions', () {
|
||||||
|
Map<String, dynamic> session(String date, String start) => {
|
||||||
|
'workout_type': 'B',
|
||||||
|
'date': date,
|
||||||
|
'start_time': start,
|
||||||
|
'duration_seconds': 3600,
|
||||||
|
'succeeded': true,
|
||||||
|
'exercises': [
|
||||||
|
{'name': 'Situp', 'targetReps': 31, 'targetWeight': 10.0, 'sets': []},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('restores sessions the local DB is missing', () async {
|
||||||
|
final restored = await _svc.restoreSyncedSessions([
|
||||||
|
session('2026-07-17', '2026-07-17T09:48:34.338'),
|
||||||
|
session('2026-07-27', '2026-07-27T09:14:22.547'),
|
||||||
|
]);
|
||||||
|
expect(restored, 2);
|
||||||
|
final hist = await _svc.getWorkoutHistory();
|
||||||
|
expect(hist.length, 2);
|
||||||
|
expect(hist.first['date'], '2026-07-27');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is idempotent — re-syncing never duplicates a session', () async {
|
||||||
|
final payloads = [session('2026-07-17', '2026-07-17T09:48:34.338')];
|
||||||
|
expect(await _svc.restoreSyncedSessions(payloads), 1);
|
||||||
|
expect(await _svc.restoreSyncedSessions(payloads), 0);
|
||||||
|
expect((await _svc.getWorkoutHistory()).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not duplicate a session the device already recorded', () async {
|
||||||
|
final payload = session('2026-08-10', '2026-08-10T10:19:44.075703');
|
||||||
|
await _svc.saveSession(
|
||||||
|
date: '2026-08-10',
|
||||||
|
workoutType: 'B',
|
||||||
|
durationSeconds: 10313,
|
||||||
|
succeeded: true,
|
||||||
|
json: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
expect(await _svc.restoreSyncedSessions([payload]), 0);
|
||||||
|
expect((await _svc.getWorkoutHistory()).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores PC-side records that are not sessions', () async {
|
||||||
|
// runnerup/manual entries have no `exercises` and belong in the synced
|
||||||
|
// list, not local history.
|
||||||
|
final restored = await _svc.restoreSyncedSessions([
|
||||||
|
{'kind': 'runnerup_verified', 'date': '2026-07-12', 'type': 'x'},
|
||||||
|
{'kind': 'manual_workout', 'date': '2026-07-13', 'type': 'y'},
|
||||||
|
]);
|
||||||
|
expect(restored, 0);
|
||||||
|
expect(await _svc.getWorkoutHistory(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty payload list is a no-op', () async {
|
||||||
|
expect(await _svc.restoreSyncedSessions([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips a payload with no date or start_time', () async {
|
||||||
|
final restored = await _svc.restoreSyncedSessions([
|
||||||
|
{'exercises': <dynamic>[], 'duration_seconds': 60},
|
||||||
|
]);
|
||||||
|
expect(restored, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a corrupt local row does not block restoring the rest', () async {
|
||||||
|
await _svc.saveSession(
|
||||||
|
date: '2026-07-01',
|
||||||
|
workoutType: 'A',
|
||||||
|
durationSeconds: 60,
|
||||||
|
succeeded: true,
|
||||||
|
json: 'not valid json',
|
||||||
|
);
|
||||||
|
final restored = await _svc.restoreSyncedSessions([
|
||||||
|
session('2026-07-17', '2026-07-17T09:48:34.338'),
|
||||||
|
]);
|
||||||
|
expect(restored, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('_backupNow clobber guard', () {
|
||||||
|
late Directory tmp;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
tmp = Directory.systemTemp.createTempSync('mw_clobber');
|
||||||
|
BackupService.baseDirForTesting = tmp.path;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
BackupService.baseDirForTesting = kBackupDir;
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to overwrite a real backup from a history-less DB', () async {
|
||||||
|
// A good backup: real progression and a real session.
|
||||||
|
await BackupService.instance.export({
|
||||||
|
'exercise_state': [
|
||||||
|
{
|
||||||
|
'name': 'Situp',
|
||||||
|
'weight': 10.0,
|
||||||
|
'reps': 31,
|
||||||
|
'success_streak': 2,
|
||||||
|
'fail_streak': 0,
|
||||||
|
'max_weight': 10.0,
|
||||||
|
'success_threshold': 5,
|
||||||
|
'fail_threshold': 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'workout_history': [
|
||||||
|
{
|
||||||
|
'date': '2026-07-17',
|
||||||
|
'workout_type': 'B',
|
||||||
|
'duration_seconds': 6465,
|
||||||
|
'succeeded': 1,
|
||||||
|
'json': '{}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'settings': [
|
||||||
|
{'key': 'last_workout_type', 'value': 'B'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The DB here is freshly seeded: defaults, and no history at all.
|
||||||
|
// Any write triggers _backupNow(), which used to export over the good
|
||||||
|
// backup. setExerciseWeight is one such write.
|
||||||
|
await _svc.setExerciseWeight('Situp', 10.0);
|
||||||
|
// Let the unawaited _backupNow() settle.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
final backup = await BackupService.instance.readBackup();
|
||||||
|
final history = backup!['workout_history'] as List;
|
||||||
|
final exercises = backup['exercise_state'] as List;
|
||||||
|
|
||||||
|
// The good backup must still be intact.
|
||||||
|
expect(history.length, 1, reason: 'real session was clobbered');
|
||||||
|
expect((history.first as Map)['date'], '2026-07-17');
|
||||||
|
final situp = exercises.firstWhere(
|
||||||
|
(e) => (e as Map)['name'] == 'Situp',
|
||||||
|
) as Map;
|
||||||
|
expect(situp['reps'], 31, reason: 'progression reps were clobbered');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still writes the backup once the DB has real history', () async {
|
||||||
|
await _svc.saveSession(
|
||||||
|
date: '2026-08-10',
|
||||||
|
workoutType: 'B',
|
||||||
|
durationSeconds: 10313,
|
||||||
|
succeeded: true,
|
||||||
|
json: '{}',
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
final backup = await BackupService.instance.readBackup();
|
||||||
|
expect(backup, isNotNull, reason: 'a real DB must still back itself up');
|
||||||
|
expect((backup!['workout_history'] as List).length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('restoreFromBackupIfNeeded', () {
|
group('restoreFromBackupIfNeeded', () {
|
||||||
late Directory tmp;
|
late Directory tmp;
|
||||||
|
|
||||||
|
|||||||
15
workout-sync.service
Normal file
15
workout-sync.service
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Pull phone workouts into the screen-locker log (crdt_sync)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# No WorkingDirectory/PYTHONPATH needed: screen_locker is pip-installed
|
||||||
|
# (pip install --user --break-system-packages -e .), so /usr/bin/python3
|
||||||
|
# finds it via user site-packages. Run as a module for the same reason the
|
||||||
|
# fit check is (see dd233b2) -- it works where nothing is installed.
|
||||||
|
#
|
||||||
|
# --sync-only is headless: no DISPLAY, no Tk, no lock screen. It pulls other
|
||||||
|
# devices' workouts and applies their credit, which the locker itself only
|
||||||
|
# did once per process start -- so a workout finished after login was invisible
|
||||||
|
# until the next login.
|
||||||
|
ExecStart=/usr/bin/python3 -m screen_locker.screen_lock --sync-only
|
||||||
12
workout-sync.timer
Normal file
12
workout-sync.timer
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Periodically pull phone workouts into the screen-locker log
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Every 15 minutes, matching diet-guard-sync.timer and the phone app's
|
||||||
|
# WorkManager floor, so both sides converge on roughly the same cadence.
|
||||||
|
# Persistent catches a run missed while the machine was suspended.
|
||||||
|
OnCalendar=*-*-* *:00/15:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Loading…
Reference in New Issue
Block a user