mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Manual-workout phone sync P1: wire format + shared constants + fixture
First phase of syncing phone-logged manual/off-app workouts to the PC so they count toward the unlock. This phase only establishes the cross-language wire contract; no behavior change to existing flows. - _manual_workout.py: build_sync_payload(draft, date) = build_entry fields + a `kind="manual_workout"` discriminator + the workout's own `date`; and manual_sync_record_id(date, start) -> stable `manual:<date>T<HH:MM>` id. A StrongLifts session payload has no `kind`, so an ingesting PC can route on it and never mistake a manual for a verified session. - test_manual_workout.py: TestSyncWireFormat pins the exact literal payload as the cross-language contract (the Dart workout_app test will reproduce the same literal byte-for-byte; neither side's own round-trip test catches drift from the other). - workout_app/lib/models/manual_workout_constants.dart: hand-maintained mirror of the 7 MANUAL_WORKOUT_* constants + the kind value (Python stays source of truth; the shared fixture is the drift tripwire). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
This commit is contained in:
parent
60d8c3361b
commit
f4d5337f25
@ -306,3 +306,39 @@ def build_entry(draft: ManualWorkoutDraft) -> dict[str, object]:
|
||||
}
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
# ── Cross-device sync wire format ─────────────────────────────────────────────
|
||||
# The value of the record ``kind`` discriminator equals ``MANUAL_WORKOUT_TYPE``
|
||||
# by design: on the wire a manual record is tagged ``kind="manual_workout"`` and
|
||||
# its payload also carries ``type="manual_workout"``. A StrongLifts session
|
||||
# payload has no ``kind`` field, so an ingesting PC routes on ``kind`` first and
|
||||
# a manual record can never be mistaken for (or stamped as) a verified session.
|
||||
MANUAL_WORKOUT_SYNC_KIND = MANUAL_WORKOUT_TYPE
|
||||
|
||||
|
||||
def manual_sync_record_id(date: str, start_time: str) -> str:
|
||||
"""Return the stable crdt_sync record id for a manual workout.
|
||||
|
||||
Prefixed ``manual:`` so it never collides with the bare-ISO session ids the
|
||||
phone app writes into the same device log. Derived only from
|
||||
``(date, start_time)`` so re-syncing the same workout is idempotent — this
|
||||
id must never be re-minted for a given workout.
|
||||
"""
|
||||
return f"manual:{date}T{start_time.strip()}"
|
||||
|
||||
|
||||
def build_sync_payload(draft: ManualWorkoutDraft, date: str) -> dict[str, object]:
|
||||
"""Build the cross-device sync payload for a manual workout.
|
||||
|
||||
This is the wire contract shared with the phone app: the same fields as
|
||||
:func:`build_entry` (what lands in ``workout_log.json``), plus a ``kind``
|
||||
discriminator and the workout's own ``date``. ``workout_log.json`` is
|
||||
day-keyed on the PC, but the synced payload must carry its date so the
|
||||
shared budget can be windowed and the entry filed in the right ISO week on
|
||||
ingest.
|
||||
"""
|
||||
payload = build_entry(draft)
|
||||
payload["kind"] = MANUAL_WORKOUT_SYNC_KIND
|
||||
payload["date"] = date
|
||||
return payload
|
||||
|
||||
@ -22,8 +22,10 @@ from screen_locker._manual_workout import (
|
||||
ManualWorkoutDraft,
|
||||
budget_summary,
|
||||
build_entry,
|
||||
build_sync_payload,
|
||||
count_in_window,
|
||||
is_budget_exhausted,
|
||||
manual_sync_record_id,
|
||||
validate_manual_workout,
|
||||
)
|
||||
|
||||
@ -294,3 +296,83 @@ class TestBuildEntry:
|
||||
def test_pain_or_injury_defaults_to_none(self) -> None:
|
||||
entry = build_entry(_tt_draft())
|
||||
assert entry["pain_or_injury"] == "none"
|
||||
|
||||
|
||||
# The canonical manual-workout sync payload. This literal is the cross-language
|
||||
# wire contract shared with the Flutter workout_app: an IDENTICAL literal lives
|
||||
# in the Dart test suite (manual_workout model test). Neither side's own
|
||||
# round-trip test can catch a key-name/format drift from the other (e.g.
|
||||
# went_well vs wentWell, duration_minutes formatting) — only this shared literal
|
||||
# on both sides does. If you change a field here, change it there too.
|
||||
_WIRE_DATE = "2026-07-13"
|
||||
_WIRE_DRAFT = ManualWorkoutDraft(
|
||||
sport=SPORT_TABLE_TENNIS,
|
||||
start_time="18:00",
|
||||
end_time="19:30",
|
||||
location_name="Solec",
|
||||
transport_method="bike",
|
||||
cost="40 PLN",
|
||||
rpe=6,
|
||||
went_well="Served consistently and moved feet well",
|
||||
to_improve="Backhand topspin needs more consistency",
|
||||
overall_feeling="Felt strong focused and in good rhythm",
|
||||
reservation_phone="600100200",
|
||||
techniques_practiced="topspin serve",
|
||||
warm_up_minutes="10",
|
||||
pain_or_injury="none",
|
||||
matches_won=3,
|
||||
matches_lost=1,
|
||||
sets_won=7,
|
||||
sets_lost=4,
|
||||
racket="Butterfly",
|
||||
balls="Nittaku 3-star",
|
||||
)
|
||||
_WIRE_PAYLOAD: dict[str, object] = {
|
||||
"type": "manual_workout",
|
||||
"source": "table tennis at Solec",
|
||||
"sport": "table_tennis",
|
||||
"activity_type": "table tennis",
|
||||
"start_time": "18:00",
|
||||
"end_time": "19:30",
|
||||
"duration_minutes": "90.0",
|
||||
"location_name": "Solec",
|
||||
"transport_method": "bike",
|
||||
"cost": "40 PLN",
|
||||
"reservation_phone": "600100200",
|
||||
"rpe": 6,
|
||||
"techniques_practiced": "topspin serve",
|
||||
"warm_up_minutes": "10",
|
||||
"pain_or_injury": "none",
|
||||
"went_well": "Served consistently and moved feet well",
|
||||
"to_improve": "Backhand topspin needs more consistency",
|
||||
"overall_feeling": "Felt strong focused and in good rhythm",
|
||||
"matches_won": 3,
|
||||
"matches_lost": 1,
|
||||
"sets_won": 7,
|
||||
"sets_lost": 4,
|
||||
"racket": "Butterfly",
|
||||
"balls": "Nittaku 3-star",
|
||||
"kind": "manual_workout",
|
||||
"date": "2026-07-13",
|
||||
}
|
||||
|
||||
|
||||
class TestSyncWireFormat:
|
||||
"""Cross-language wire-format contract for the manual-workout sync payload."""
|
||||
|
||||
def test_build_sync_payload_matches_fixture(self) -> None:
|
||||
assert build_sync_payload(_WIRE_DRAFT, _WIRE_DATE) == _WIRE_PAYLOAD
|
||||
|
||||
def test_sync_payload_is_json_serializable(self) -> None:
|
||||
# The Dart side round-trips the same literal through JSON; guard that our
|
||||
# payload contains only JSON-native types (no stray dataclass/objects).
|
||||
restored = json.loads(json.dumps(build_sync_payload(_WIRE_DRAFT, _WIRE_DATE)))
|
||||
assert restored == _WIRE_PAYLOAD
|
||||
|
||||
def test_record_id_is_stable_and_prefixed(self) -> None:
|
||||
assert manual_sync_record_id(_WIRE_DATE, "18:00") == "manual:2026-07-13T18:00"
|
||||
|
||||
def test_kind_discriminator_present_and_typed(self) -> None:
|
||||
payload = build_sync_payload(_WIRE_DRAFT, _WIRE_DATE)
|
||||
# Absent-kind means a StrongLifts session; a manual must be tagged.
|
||||
assert payload["kind"] == "manual_workout"
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
/// Shared constants for manual (off-app) workout logging.
|
||||
///
|
||||
/// These mirror the Python source of truth in the screen-locker package
|
||||
/// (`screen_locker/_constants.py`, the `MANUAL_WORKOUT_*` values). The two
|
||||
/// languages cannot share a constants file, so this is a hand-maintained
|
||||
/// mirror; the cross-language sync-payload fixture (see the manual_workout
|
||||
/// model test here and its Python twin,
|
||||
/// `test_manual_workout.py::TestSyncWireFormat`) is the tripwire that catches
|
||||
/// drift. Keep these values in sync with `_constants.py`.
|
||||
library;
|
||||
|
||||
/// Max manual workouts allowed in any rolling 7-day window.
|
||||
const int kManualWorkoutBudgetPer7Days = 2;
|
||||
|
||||
/// Max manual workouts allowed in any rolling 30-day window.
|
||||
const int kManualWorkoutBudgetPer30Days = 5;
|
||||
|
||||
/// Minimum session length, in minutes, for a manual workout to count.
|
||||
const int kManualWorkoutMinDurationMinutes = 20;
|
||||
|
||||
/// Minimum characters for the "other sport" activity description.
|
||||
const int kManualWorkoutDescriptionMinChars = 40;
|
||||
|
||||
/// Minimum characters required in each reflection field.
|
||||
const int kManualWorkoutReflectionMinChars = 20;
|
||||
|
||||
/// Lowest valid RPE (rate of perceived exertion).
|
||||
const int kManualWorkoutRpeMin = 1;
|
||||
|
||||
/// Highest valid RPE (rate of perceived exertion).
|
||||
const int kManualWorkoutRpeMax = 10;
|
||||
|
||||
/// Record `kind` discriminator marking a manual-workout sync record.
|
||||
///
|
||||
/// A StrongLifts session payload has no `kind`; the ingesting PC routes on this
|
||||
/// value before any session logic, so a manual can never be stamped as a
|
||||
/// verified session.
|
||||
const String kManualWorkoutSyncKind = 'manual_workout';
|
||||
Loading…
Reference in New Issue
Block a user