From 54bcbe4e1803e948ce6d56e603edcbb097704076 Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Sun, 6 Sep 2026 10:54:59 +0200 Subject: [PATCH 1/3] feat: add create_gear() to register new equipment via the API add_gear_to_activity()/remove_gear_from_activity() only link/unlink existing gear; there was no way to register a new piece of gear itself without a manual detour through the Garmin Connect app (issue 418). Mirrors the payload Garmin Connect's web "Add Gear" form sends to gear-service/gear/v2, captured from a real account. Only gear_type="SHOES" and usage_type="DISTANCE" have been confirmed; other values are accepted as plain strings (normalized to the confirmed SCREAMING_SNAKE_CASE convention) since the full enum isn't publicly documented and guessing at it risks silently wrong behavior. Closes #418 --- garminconnect/__init__.py | 93 +++++++++++++++++++++++++++++++ tests/test_garmin_unit.py | 112 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/garminconnect/__init__.py b/garminconnect/__init__.py index df219fbc..7e6f94d0 100644 --- a/garminconnect/__init__.py +++ b/garminconnect/__init__.py @@ -2777,6 +2777,99 @@ def get_gear(self, userProfileNumber: str) -> dict[str, Any]: return self.connectapi(url, params={"userProfilePk": userProfileNumber}) + def create_gear( + self, + gear_type: str, + brand: str, + model: str, + name: str, + first_use_date: str, + usage_type: str = "DISTANCE", + max_usage_distance_km: float | None = None, + max_usage_duration_min: float | None = None, + notes: str = "", + activity_type_keys: list[str] | None = None, + ) -> Any: + """Create a new piece of gear (e.g. a pair of shoes) and return it. + + Mirrors the payload the Garmin Connect web "Add Gear" form sends to + ``gear-service/gear/v2``. Only ``gear_type="SHOES"`` and + ``usage_type="DISTANCE"`` have been confirmed against a real + account; other gear/usage type values are almost certainly also + SCREAMING_SNAKE_CASE (e.g. "BIKE", "TIME") but are unverified — if + one is rejected, check the "Gear Type"/"Usage Tracking" dropdown + option values on the Garmin Connect "Add Gear" page. + + :param gear_type: Gear category, e.g. "SHOES". + :param brand: Brand/make name, e.g. "Anta". + :param model: Model name, e.g. "A-Flash". + :param name: Nickname shown in Garmin Connect, e.g. "Test". + :param first_use_date: Date gear was first used, "YYYY-MM-DD". + :param usage_type: How usage is tracked, e.g. "DISTANCE" or "TIME". + :param max_usage_distance_km: Optional retirement threshold in km. + :param max_usage_duration_min: Optional retirement threshold in minutes. + :param notes: Optional free-text notes. + :param activity_type_keys: Optional activity type keys (e.g. + ["running"], lowercase — matching :meth:`get_activities`' + ``activitytype``, not :meth:`set_gear_default`'s uppercase + convention) to associate as default gear for those activities. + :return: The created gear record from Garmin. + """ + gear_type = _validate_sport_key(gear_type, "gear_type") + usage_type = _validate_sport_key(usage_type, "usage_type") + if not isinstance(brand, str) or not brand.strip(): + raise ValueError("brand must be a non-empty string") + if not isinstance(model, str) or not model.strip(): + raise ValueError("model must be a non-empty string") + if not isinstance(name, str) or not name.strip(): + raise ValueError("name must be a non-empty string") + first_use_date = _validate_date_format(first_use_date, "first_use_date") + + max_usage_distance_meters = 0 + if max_usage_distance_km is not None: + max_usage_distance_meters = round( + _validate_positive_number( + max_usage_distance_km, "max_usage_distance_km" + ) + * 1000 + ) + + max_usage_duration_seconds = 0 + if max_usage_duration_min is not None: + max_usage_duration_seconds = round( + _validate_positive_number( + max_usage_duration_min, "max_usage_duration_min" + ) + * 60 + ) + + associated_activity_types = [] + for key in activity_type_keys or []: + if not isinstance(key, str) or not key.strip(): + raise ValueError("activity_type_keys entries must be non-empty strings") + associated_activity_types.append( + {"activityTypeKey": key, "defaultGear": True, "preferredGear": False} + ) + + payload = { + "uuid": None, + "gearType": gear_type, + "brand": brand, + "model": model, + "name": name, + "firstUseDate": first_use_date, + "maxUsageDate": None, + "maxUsageDistanceMeters": max_usage_distance_meters, + "maxUsageDurationSeconds": max_usage_duration_seconds, + "usageType": usage_type, + "notes": notes, + "associatedActivityTypes": associated_activity_types, + } + + url = f"{self.garmin_connect_gear_baseurl}/v2" + logger.debug("Creating gear: %s", payload) + return self.client.post("connectapi", url, json=payload, api=True) + def get_gear_stats(self, gearUUID: str) -> dict[str, Any]: """Return statistics (e.g. distance) for specific gear UUID.""" gearUUID = _validate_uuid(gearUUID, "gearUUID") diff --git a/tests/test_garmin_unit.py b/tests/test_garmin_unit.py index 15dfc329..6face80a 100644 --- a/tests/test_garmin_unit.py +++ b/tests/test_garmin_unit.py @@ -2396,6 +2396,118 @@ def test_delete_blood_pressure_validates_date(self, garmin: garminconnect.Garmin garmin.delete_blood_pressure("1", "not-a-date") +# --------------------------------------------------------------------------- +# create_gear: matches the payload captured from Garmin Connect's "Add Gear" +# --------------------------------------------------------------------------- + + +class TestCreateGear: + def test_builds_payload_matching_captured_request( + self, garmin: garminconnect.Garmin + ): + with patch.object(garmin.client, "post") as mock_post: + garmin.create_gear( + gear_type="SHOES", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + usage_type="DISTANCE", + max_usage_distance_km=650, + notes="My notes", + activity_type_keys=["running"], + ) + + payload = mock_post.call_args.kwargs["json"] + assert payload == { + "uuid": None, + "gearType": "SHOES", + "brand": "Anta", + "model": "A-Flash", + "name": "Test", + "firstUseDate": "2026-09-06", + "maxUsageDate": None, + "maxUsageDistanceMeters": 650000, + "maxUsageDurationSeconds": 0, + "usageType": "DISTANCE", + "notes": "My notes", + "associatedActivityTypes": [ + {"activityTypeKey": "running", "defaultGear": True, "preferredGear": False} + ], + } + url = mock_post.call_args[0][1] + assert url.endswith("/gear-service/gear/v2") + + def test_normalizes_lowercase_gear_and_usage_type( + self, garmin: garminconnect.Garmin + ): + with patch.object(garmin.client, "post") as mock_post: + garmin.create_gear( + gear_type="shoes", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + usage_type="distance", + ) + + payload = mock_post.call_args.kwargs["json"] + assert payload["gearType"] == "SHOES" + assert payload["usageType"] == "DISTANCE" + + def test_does_not_uppercase_activity_type_key(self, garmin: garminconnect.Garmin): + """activityTypeKey is lowercase in the confirmed payload — unlike + set_gear_default()'s uppercase activityType convention. + """ + with patch.object(garmin.client, "post") as mock_post: + garmin.create_gear( + gear_type="SHOES", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + activity_type_keys=["running"], + ) + + payload = mock_post.call_args.kwargs["json"] + assert payload["associatedActivityTypes"][0]["activityTypeKey"] == "running" + + def test_rejects_invalid_first_use_date(self, garmin: garminconnect.Garmin): + with pytest.raises(ValueError, match="YYYY-MM-DD"): + garmin.create_gear( + gear_type="SHOES", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="not-a-date", + ) + + def test_rejects_empty_brand(self, garmin: garminconnect.Garmin): + with pytest.raises(ValueError, match="brand"): + garmin.create_gear( + gear_type="SHOES", + brand="", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + ) + + def test_omits_activity_types_by_default(self, garmin: garminconnect.Garmin): + with patch.object(garmin.client, "post") as mock_post: + garmin.create_gear( + gear_type="SHOES", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + ) + + payload = mock_post.call_args.kwargs["json"] + assert payload["associatedActivityTypes"] == [] + assert payload["maxUsageDistanceMeters"] == 0 + assert payload["maxUsageDurationSeconds"] == 0 + + # --------------------------------------------------------------------------- # Activity upload filename handling # --------------------------------------------------------------------------- From 34fc406f045a22a8808111164c809191e57d4072 Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Wed, 9 Sep 2026 13:21:23 +0200 Subject: [PATCH 2/3] demo: wire up activitysubtype filter and gear creation, update README stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds interactive demo.py menu entries for get_activities()'s activitysubtype filter (already in the library, never had a demo entry) and for create_gear(). The activity type/subtype picker pulls the live type list from api.get_activity_types() rather than requiring a config edit, so it always reflects the account's real taxonomy instead of a guessed one. Updates the API coverage numbers in README, which had drifted well before these two additions (missing an entire "Activity Editing" category, wrong Training Plans count) — recomputed directly from demo.py's menu_categories rather than hand-counted. --- README.md | 19 ++++----- demo.py | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 22db7e01..a9866356 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The Garmin Connect API library comes with two examples: - **`example.py`** - Simple getting-started example showing authentication, token storage, and basic API calls -- **`demo.py`** - Comprehensive demo providing access to **130+ API methods** organized into **13 categories** for easy navigation +- **`demo.py`** - Comprehensive demo providing access to **145+ API methods** organized into **14 categories** for easy navigation ```bash $ ./demo.py @@ -50,21 +50,22 @@ Make your selection: ## API Coverage Statistics -- **Total API Methods**: 144+ unique endpoints (snapshot) -- **Categories**: 13 organized sections +- **Total API Methods**: 145+ unique endpoints (snapshot) +- **Categories**: 14 organized sections - **User & Profile**: 4 methods (basic user info, settings) - **Daily Health & Activity**: 10 methods (today's health data plus daily calories, resting HR and sleep ranges) - **Advanced Health Metrics**: 16 methods (fitness metrics, HRV, VO2, FTP range, training readiness, training zones, running tolerance) - **Historical Data & Trends**: 9 methods (date range queries, weekly aggregates) -- **Activities & Workouts**: 41 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, edit description / exercise sets) -- **Body Composition & Weight**: 8 methods (weight tracking, body composition) +- **Activities & Workouts**: 36 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, activity type/subtype filtering) +- **Body Composition & Weight**: 7 methods (weight tracking, body composition) - **Goals & Achievements**: 15 methods (challenges, badges, goals) - **Device & Technical**: 7 methods (device info, settings) -- **Gear & Equipment**: 7 methods (gear management, tracking) +- **Gear & Equipment**: 8 methods (gear management, tracking, creation) - **Hydration & Wellness**: 12 methods (hydration, nutrition, blood pressure, menstrual) - **System & Export**: 5 methods (reporting, logout, GraphQL, health snapshot download) -- **Training Plans**: 3 methods (plans, plan by ID, adaptive plan by ID) +- **Training Plans**: 9 methods (plans, plan by ID, typed strength workout upload, exercise catalog search, in-place workout editing, push to device, workout scheduling management) - **Golf**: 5 methods (scorecard summary, scorecard detail, shot data, club stats, user stats) +- **Activity Editing**: 2 methods (set activity description, set strength-activity exercise sets) ### Interactive Features @@ -113,7 +114,7 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate pip install -e ".[example]" python3 ./example.py # simple getting-started example -python3 ./demo.py # comprehensive demo (130+ API methods) +python3 ./demo.py # comprehensive demo (145+ API methods) ``` ## 🛠️ Development @@ -459,7 +460,7 @@ user_stats = client.get_golf_user_stats() ### Additional Resources - **Simple Example**: [example.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/example.py) - Getting started guide -- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 130+ API methods +- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 145+ API methods - **API Documentation**: Comprehensive method documentation in source code - **Test Cases**: Real-world usage examples in `tests/` directory diff --git a/demo.py b/demo.py index 4e0e4fa1..082ab02a 100755 --- a/demo.py +++ b/demo.py @@ -147,7 +147,6 @@ def __init__(self): self.start_badge = 1 # Badge related calls start counting at 1 # Activity settings - self.activitytype = "" # Possible values: cycling, running, swimming, multi_sport, fitness_equipment, hiking, walking, other self.activityfile = "test_data/*.gpx" # Supported file types: .fit .gpx .tcx self.workoutfile = "test_data/sample_workout.json" # Sample workout JSON file @@ -434,6 +433,10 @@ def __init__(self): "desc": "Upload typed hiking workout (sample)", "key": "upload_hiking_workout", }, + "A": { + "desc": "Get activities filtered by type/subtype (interactive)", + "key": "get_activities_filtered", + }, }, }, "6": { @@ -532,6 +535,10 @@ def __init__(self): "desc": "Add and remove gear to/from activity (interactive)", "key": "add_and_remove_gear_to_activity", }, + "8": { + "desc": "Create new gear, e.g. shoes (interactive)", + "key": "create_gear", + }, }, }, "0": { @@ -3491,6 +3498,110 @@ def add_and_remove_gear_to_activity(api: Garmin) -> None: print(f"❌ Error adding gear: {e}") +def get_activities_filtered_data(api: Garmin) -> None: + """Get activities filtered by type/subtype, picked from the account's own activity type list.""" + try: + activity_types = api.get_activity_types() + print("\nAvailable activity types:") + for i, activity_type in enumerate(activity_types): + print( + f"{i}: {activity_type.get('typeKey', 'Unknown')} - {activity_type.get('display', 'No description')}" + ) + + type_index = input( + "\nEnter activity type index to filter by (blank for no filter): " + ).strip() + + activitytype = None + if type_index: + try: + activitytype = activity_types[int(type_index)]["typeKey"] + except (ValueError, IndexError): + print("❌ Invalid index, no type filter applied") + + activitysubtype = None + if activitytype: + hint = ( + " (e.g. 'strength_training')" + if activitytype == "fitness_equipment" + else "" + ) + activitysubtype = ( + input(f"Activity subtype{hint} (blank for none): ").strip() or None + ) + + call_and_display( + api.get_activities, + config.start, + config.default_limit, + activitytype=activitytype, + activitysubtype=activitysubtype, + method_name="get_activities_filtered", + api_call_desc=( + f"api.get_activities({config.start}, {config.default_limit}, " + f"activitytype={activitytype!r}, activitysubtype={activitysubtype!r})" + ), + ) + except Exception as e: + print(f"❌ Error getting filtered activities: {e}") + + +def create_gear_data(api: Garmin) -> None: + """Create a new piece of gear, e.g. a pair of shoes.""" + try: + print("Creating new gear...") + print("Enter gear details (press Enter for defaults):") + + gear_type = input("Gear type [SHOES]: ").strip() or "SHOES" + brand = input("Brand [Anta]: ").strip() or "Anta" + model = input("Model [A-Flash]: ").strip() or "A-Flash" + name = input("Nickname [Test]: ").strip() or "Test" + first_use_date = ( + input(f"First use date [{config.today.isoformat()}]: ").strip() + or config.today.isoformat() + ) + usage_type = input("Usage tracking type [DISTANCE]: ").strip() or "DISTANCE" + max_km = input("Max use threshold in km (blank for none): ").strip() + activity_types_input = input( + "Default activity types, comma-separated [running]: " + ).strip() + activity_type_keys = [ + key.strip() + for key in (activity_types_input or "running").split(",") + if key.strip() + ] + notes = input("Notes (blank for none): ").strip() + + try: + max_usage_distance_km = float(max_km) if max_km else None + + call_and_display( + api.create_gear, + gear_type=gear_type, + brand=brand, + model=model, + name=name, + first_use_date=first_use_date, + usage_type=usage_type, + max_usage_distance_km=max_usage_distance_km, + notes=notes, + activity_type_keys=activity_type_keys, + method_name="create_gear", + api_call_desc=( + f"api.create_gear(gear_type='{gear_type}', brand='{brand}', " + f"model='{model}', name='{name}', " + f"first_use_date='{first_use_date}', usage_type='{usage_type}', " + f"max_usage_distance_km={max_usage_distance_km}, " + f"activity_type_keys={activity_type_keys})" + ), + ) + print("✅ Gear created!") + except ValueError: + print("❌ Invalid numeric input") + except Exception as e: + print(f"❌ Error creating gear: {e}") + + def set_activity_name_data(api: Garmin) -> None: """Set activity name.""" try: @@ -4351,6 +4462,7 @@ def execute_api_call(api: Garmin, key: str) -> None: method_name="get_activities", api_call_desc=f"api.get_activities({config.start}, {config.default_limit})", ), + "get_activities_filtered": lambda: get_activities_filtered_data(api), "get_last_activity": lambda: call_and_display( api.get_last_activity, method_name="get_last_activity", @@ -4596,6 +4708,7 @@ def execute_api_call(api: Garmin, key: str) -> None: "add_and_remove_gear_to_activity": lambda: add_and_remove_gear_to_activity( api ), + "create_gear": lambda: create_gear_data(api), # Hydration & Wellness "get_hydration_data": lambda: call_and_display( api.get_hydration_data, @@ -4899,7 +5012,7 @@ def main(): # Handle category menu options if option == "q": current_category = None # Back to main menu - elif option in "0123456789abcdefghijklmnopqrstuvwxyz": + elif option in "0123456789abcdefghijklmnopqrstuvwxyzA": try: category_data = menu_categories[current_category] category_options = category_data["options"] From f5a52523b853f33971c210b2dbdc1b5aabba3ed3 Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Wed, 9 Sep 2026 13:35:25 +0200 Subject: [PATCH 3/3] fix: address CodeRabbit findings on create_gear() and its demo wiring - _validate_positive_number() now rejects non-finite values (inf/nan), which previously passed through and then raised an uncaught OverflowError from round() in create_gear(). - create_gear() rejects a max_usage_distance_km/max_usage_duration_min small enough to round to 0 after unit conversion, since 0 collides with the payload's "no threshold" sentinel. - create_gear() rejects a non-list activity_type_keys (e.g. a bare string), which iterated character-by-character and silently created one bogus activity association per character. - demo.py's activity-type picker rejects negative indexes, which are valid Python list indexing (selects from the end) but not a valid menu selection. - demo.py's create_gear_data() only prints success after checking the success flag call_and_display() returns, instead of unconditionally. Added regression tests for all five in tests/test_garmin_unit.py and a new tests/test_demo_gear_and_filters.py. --- demo.py | 13 ++-- garminconnect/__init__.py | 21 +++++- tests/test_demo_gear_and_filters.py | 100 ++++++++++++++++++++++++++++ tests/test_garmin_unit.py | 61 +++++++++++++++++ 4 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 tests/test_demo_gear_and_filters.py diff --git a/demo.py b/demo.py index 082ab02a..ad0a3d34 100755 --- a/demo.py +++ b/demo.py @@ -3515,8 +3515,12 @@ def get_activities_filtered_data(api: Garmin) -> None: activitytype = None if type_index: try: - activitytype = activity_types[int(type_index)]["typeKey"] - except (ValueError, IndexError): + idx = int(type_index) + if 0 <= idx < len(activity_types): + activitytype = activity_types[idx]["typeKey"] + else: + print("❌ Invalid index, no type filter applied") + except ValueError: print("❌ Invalid index, no type filter applied") activitysubtype = None @@ -3575,7 +3579,7 @@ def create_gear_data(api: Garmin) -> None: try: max_usage_distance_km = float(max_km) if max_km else None - call_and_display( + success, _ = call_and_display( api.create_gear, gear_type=gear_type, brand=brand, @@ -3595,7 +3599,8 @@ def create_gear_data(api: Garmin) -> None: f"activity_type_keys={activity_type_keys})" ), ) - print("✅ Gear created!") + if success: + print("✅ Gear created!") except ValueError: print("❌ Invalid numeric input") except Exception as e: diff --git a/garminconnect/__init__.py b/garminconnect/__init__.py index 7e6f94d0..00a129f4 100644 --- a/garminconnect/__init__.py +++ b/garminconnect/__init__.py @@ -3,6 +3,7 @@ import contextlib import functools import logging +import math import numbers import os import random @@ -95,13 +96,16 @@ def _validate_date_range(start: str, end: str) -> tuple[str, str]: def _validate_positive_number( value: int | float, param_name: str = "value" ) -> int | float: - """Validate that a number is positive.""" + """Validate that a number is positive and finite.""" if not isinstance(value, numbers.Real): raise ValueError(f"{param_name} must be a number") if isinstance(value, bool): raise ValueError(f"{param_name} must be a number, not bool") + if not math.isfinite(value): + raise ValueError(f"{param_name} must be finite, got: {value}") + if value <= 0: raise ValueError(f"{param_name} must be positive, got: {value}") @@ -2833,6 +2837,12 @@ def create_gear( ) * 1000 ) + if max_usage_distance_meters < 1: + raise ValueError( + "max_usage_distance_km must be at least 0.001 (1 meter) — " + "a smaller value would round down to 0, which means " + "'no threshold' rather than the value requested" + ) max_usage_duration_seconds = 0 if max_usage_duration_min is not None: @@ -2842,6 +2852,15 @@ def create_gear( ) * 60 ) + if max_usage_duration_seconds < 1: + raise ValueError( + "max_usage_duration_min must be at least 1/60 (1 second) — " + "a smaller value would round down to 0, which means " + "'no threshold' rather than the value requested" + ) + + if activity_type_keys is not None and not isinstance(activity_type_keys, list): + raise ValueError("activity_type_keys must be a list of strings") associated_activity_types = [] for key in activity_type_keys or []: diff --git a/tests/test_demo_gear_and_filters.py b/tests/test_demo_gear_and_filters.py new file mode 100644 index 00000000..f8b0b428 --- /dev/null +++ b/tests/test_demo_gear_and_filters.py @@ -0,0 +1,100 @@ +"""Regression tests for demo.py's interactive gear/activity-filter helpers.""" + +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.activity_types = [ + {"typeId": 1, "typeKey": "running", "display": "Running"}, + { + "typeId": 2, + "typeKey": "fitness_equipment", + "display": "Fitness Equipment", + }, + ] + self.get_activities_calls: list[dict] = [] + self.create_gear_calls: list[dict] = [] + self.create_gear_result: dict | None = {"uuid": "new-gear"} + + def get_activity_types(self): + return self.activity_types + + def get_activities(self, start, limit, **kwargs): + self.get_activities_calls.append({"start": start, "limit": limit, **kwargs}) + return [] + + def create_gear(self, **kwargs): + self.create_gear_calls.append(kwargs) + if self.create_gear_result is None: + raise ValueError("rejected by server") + return self.create_gear_result + + +def _typed_inputs(*values): + it = iter(values) + return lambda prompt="": next(it) + + +def test_negative_activity_type_index_is_rejected(monkeypatch, capsys): + """A negative index is valid Python list indexing (selects from the end) + but must not silently apply an unintended filter. + """ + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("-1")) + + demo.get_activities_filtered_data(api) + + assert api.get_activities_calls == [ + {"start": 0, "limit": 100, "activitytype": None, "activitysubtype": None} + ] + assert "Invalid index" in capsys.readouterr().out + + +def test_out_of_range_activity_type_index_is_rejected(monkeypatch): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("99")) + + demo.get_activities_filtered_data(api) + + assert api.get_activities_calls[0]["activitytype"] is None + + +def test_valid_activity_type_index_applies_filter(monkeypatch): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("0", "")) + + demo.get_activities_filtered_data(api) + + assert api.get_activities_calls[0]["activitytype"] == "running" + + +def test_create_gear_success_message_only_on_success(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr( + "builtins.input", _typed_inputs("", "", "", "", "", "", "", "", "") + ) + + demo.create_gear_data(api) + + assert "✅ Gear created!" in capsys.readouterr().out + + +def test_create_gear_no_success_message_when_request_fails(monkeypatch, capsys): + """call_and_display() returns (False, None) on a rejected/failed request — + the success message must not print unconditionally. + """ + api = _FakeAPI() + api.create_gear_result = None + monkeypatch.setattr( + "builtins.input", _typed_inputs("", "", "", "", "", "", "", "", "") + ) + + demo.create_gear_data(api) + + assert "✅ Gear created!" not in capsys.readouterr().out diff --git a/tests/test_garmin_unit.py b/tests/test_garmin_unit.py index 6face80a..60f04a74 100644 --- a/tests/test_garmin_unit.py +++ b/tests/test_garmin_unit.py @@ -2507,6 +2507,67 @@ def test_omits_activity_types_by_default(self, garmin: garminconnect.Garmin): assert payload["maxUsageDistanceMeters"] == 0 assert payload["maxUsageDurationSeconds"] == 0 + def test_rejects_non_finite_max_usage_distance(self, garmin: garminconnect.Garmin): + with pytest.raises(ValueError, match="finite"): + garmin.create_gear( + gear_type="SHOES", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + max_usage_distance_km=float("inf"), + ) + + def test_rejects_non_finite_max_usage_duration(self, garmin: garminconnect.Garmin): + with pytest.raises(ValueError, match="finite"): + garmin.create_gear( + gear_type="SHOES", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + max_usage_duration_min=float("nan"), + ) + + def test_rejects_sub_meter_max_usage_distance(self, garmin: garminconnect.Garmin): + """A positive value that rounds to 0 meters would silently collide + with the 'no threshold' sentinel instead of applying a threshold. + """ + with pytest.raises(ValueError, match="max_usage_distance_km"): + garmin.create_gear( + gear_type="SHOES", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + max_usage_distance_km=0.0001, + ) + + def test_rejects_sub_second_max_usage_duration(self, garmin: garminconnect.Garmin): + with pytest.raises(ValueError, match="max_usage_duration_min"): + garmin.create_gear( + gear_type="SHOES", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + max_usage_duration_min=0.001, + ) + + def test_rejects_string_activity_type_keys(self, garmin: garminconnect.Garmin): + """A bare string is iterable — without a container-type check this + would silently create one bogus association per character. + """ + with pytest.raises(ValueError, match="activity_type_keys"): + garmin.create_gear( + gear_type="SHOES", + brand="Anta", + model="A-Flash", + name="Test", + first_use_date="2026-09-06", + activity_type_keys="running", + ) + # --------------------------------------------------------------------------- # Activity upload filename handling