diff --git a/demo.py b/demo.py index ad0a3d34..a1b0bca4 100755 --- a/demo.py +++ b/demo.py @@ -469,7 +469,10 @@ def __init__(self): "7": { "name": "🏆 Goals & Achievements", "options": { - "1": {"desc": "Get personal records", "key": "get_personal_records"}, + "1": { + "desc": "Get personal records (decodes running typeId, interactive)", + "key": "get_personal_records", + }, "2": {"desc": "Get earned badges", "key": "get_earned_badges"}, "3": {"desc": "Get adhoc challenges", "key": "get_adhoc_challenges"}, "4": { @@ -3607,6 +3610,147 @@ def create_gear_data(api: Garmin) -> None: print(f"❌ Error creating gear: {e}") +def _format_record_duration(seconds: float) -> str: + """Format a personal-record time value (seconds) as h:mm:ss or m:ss.""" + total = round(seconds) + hours, remainder = divmod(total, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + + +def _format_record_distance(meters: float) -> str: + """Format a personal-record distance value (meters) as km.""" + return f"{meters / 1000:.2f} km" + + +def _format_record_count(value: float) -> str: + """Format a personal-record count value (e.g. steps) with thousands separators.""" + return f"{round(value):,}" + + +def _format_record_days(value: float) -> str: + """Format a personal-record day-count value (e.g. a goal streak).""" + days = round(value) + return f"{days} day" if days == 1 else f"{days} days" + + +_RECORD_FORMATTERS = { + "time": _format_record_duration, + "distance": _format_record_distance, + "count": _format_record_count, + "days": _format_record_days, +} + + +def get_personal_records_data(api: Garmin) -> None: + """Get personal records, decoding typeId into a human-readable label + and value, and optionally looking up the source activity's details + (needed for longest-run duration, which isn't included in the + personal-record entry itself). + + Two typeId ranges are confirmed against a real account's Garmin + Connect "Personal Records" page: + - activityType == "running": typeIds 1-6 are best-time records for + a fixed distance (value is a duration in seconds), typeId 7 is a + distance record (value is meters). + - activityType is None: typeIds 12-14 are step counts (day/week/ + month), 15-16 are goal-streak day counts. + Anything else is shown as an unconfirmed raw value — check the Garmin + Connect "Personal Records" page for what it actually is rather than + trusting a guess here. + """ + # (label, formatter key) — see _RECORD_FORMATTERS. Confirmed against a + # real account; see docstring. + running_type_info = { + 1: ("1 km", "time"), + 2: ("1 mile", "time"), + 3: ("5 km", "time"), + 4: ("10 km", "time"), + 5: ("Half marathon", "time"), + 6: ("Marathon", "time"), + 7: ("Longest run", "distance"), + } + # activityType is None for these — steps/streak records, not tied to a + # specific sport. + other_type_info = { + 12: ("Most steps in a day", "count"), + 13: ("Most steps in a week", "count"), + 14: ("Most steps in a month", "count"), + 15: ("Longest goal streak", "days"), + 16: ("Current goal streak", "days"), + } + try: + success, records = call_and_display( + api.get_personal_record, + method_name="get_personal_record", + api_call_desc="api.get_personal_record()", + ) + if not success or not records: + return + + entries = records if isinstance(records, list) else [records] + if not entries: + print("â„šī¸ No personal records found") + return + + print("\nPersonal records:") + for i, entry in enumerate(entries): + type_id = entry.get("typeId") + activity_type = entry.get("activityType") + value = entry.get("value") + if activity_type == "running": + info = running_type_info.get(type_id) + elif activity_type is None: + info = other_type_info.get(type_id) + else: + info = None + + if info is not None and isinstance(value, int | float): + label, kind = info + formatted = _RECORD_FORMATTERS[kind](value) + print( + f"{i}: {label} — {formatted} (typeId={type_id}, raw value={value})" + ) + else: + print( + f"{i}: Unconfirmed record type " + f"(typeId={type_id}, activityType={activity_type!r}, raw value={value})" + ) + + choice = input( + "\nEnter index to look up the source activity's details (blank to skip): " + ).strip() + if not choice: + return + + try: + idx = int(choice) + if not (0 <= idx < len(entries)): + print("❌ Invalid index") + return + except ValueError: + print("❌ Invalid index") + return + + activity_id = entries[idx].get("activityId") or entries[idx].get( + "activityIdInt" + ) + if not activity_id: + print("â„šī¸ This record entry has no activityId to look up") + return + + call_and_display( + api.get_activity, + str(activity_id), + method_name="get_activity", + api_call_desc=f"api.get_activity('{activity_id}')", + ) + except Exception as e: + print(f"❌ Error getting personal records: {e}") + + def set_activity_name_data(api: Garmin) -> None: """Set activity name.""" try: @@ -4580,11 +4724,7 @@ def execute_api_call(api: Garmin, key: str) -> None: "delete_weigh_ins": lambda: delete_weigh_ins_data(api), "delete_weigh_in": lambda: delete_weigh_in_data(api), # Goals & Achievements - "get_personal_records": lambda: call_and_display( - api.get_personal_record, - method_name="get_personal_record", - api_call_desc="api.get_personal_record()", - ), + "get_personal_records": lambda: get_personal_records_data(api), "get_earned_badges": lambda: call_and_display( api.get_earned_badges, method_name="get_earned_badges", diff --git a/tests/test_demo_personal_records.py b/tests/test_demo_personal_records.py new file mode 100644 index 00000000..86d9783d --- /dev/null +++ b/tests/test_demo_personal_records.py @@ -0,0 +1,128 @@ +"""Regression tests for demo.py's personal-records interactive helper.""" + +import sys +from types import SimpleNamespace + +sys.modules.setdefault("readchar", SimpleNamespace(readkey=lambda: "q")) # type: ignore[arg-type] + +import demo # noqa: E402 + + +class _FakeAPI: + def __init__(self): + self.records = [ + { + "typeId": 4, + "activityType": "running", + "value": 2400, + "activityId": 111, + }, + {"typeId": 7, "activityType": "running", "value": 5000}, + {"typeId": 12, "activityType": None, "value": 26246.0}, + {"typeId": 15, "activityType": None, "value": 1.0}, + {"typeId": 1, "activityType": "cycling", "value": 999}, + {"typeId": 99, "activityType": None, "value": 42.0}, + ] + self.get_activity_calls: list[str] = [] + + def get_personal_record(self): + return self.records + + def get_activity(self, activity_id): + self.get_activity_calls.append(activity_id) + return {"activityId": activity_id} + + +def _typed_inputs(*values): + it = iter(values) + return lambda prompt="": next(it) + + +def test_decodes_running_time_record(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert "10 km — 40:00" in out + assert "typeId=4, raw value=2400" in out + + +def test_decodes_running_distance_record(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert "Longest run — 5.00 km" in out + + +def test_decodes_step_count_record(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert "Most steps in a day — 26,246" in out + + +def test_decodes_goal_streak_singular_day(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert "Longest goal streak — 1 day " in out + assert "1 days" not in out + + +def test_unconfirmed_types_are_shown_not_hidden(monkeypatch, capsys): + """Records outside both confirmed typeId tables must still be printed + in full (typeId + raw value), not dropped or given a made-up label. + """ + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert ( + "Unconfirmed record type (typeId=1, activityType='cycling', raw value=999)" + in out + ) + assert ( + "Unconfirmed record type (typeId=99, activityType=None, raw value=42.0)" in out + ) + + +def test_looks_up_source_activity_by_index(monkeypatch): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("0")) + + demo.get_personal_records_data(api) + + assert api.get_activity_calls == ["111"] + + +def test_negative_index_is_rejected(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("-1")) + + demo.get_personal_records_data(api) + + assert api.get_activity_calls == [] + assert "Invalid index" in capsys.readouterr().out + + +def test_entry_without_activity_id_is_reported(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("1")) + + demo.get_personal_records_data(api) + + assert api.get_activity_calls == [] + assert "no activityId" in capsys.readouterr().out