""" One activity, in full. Garmin returns the sampled series as a column store — a descriptor list plus rows of parallel values — so the fragile part is reading each metric out by the index its descriptor names. Getting that wrong silently plots the wrong sensor. """ import json import pytest from services import garmin as svc def descriptors(*keys): return [{"key": k, "metricsIndex": i} for i, k in enumerate(keys)] def rows(*tuples): return [{"metrics": list(t)} for t in tuples] class FakeClient: """Stands in for garminconnect.Garmin with recorded responses.""" def __init__(self, **responses): self.responses = responses self.calls = [] def _get(self, name, default): self.calls.append(name) value = self.responses.get(name, default) if isinstance(value, Exception): raise value return value def get_activity_evaluation(self, _id): return self._get("evaluation", {}) def get_activity_details(self, _id, maxchart=None, maxpoly=None): self.maxchart = maxchart self.maxpoly = maxpoly return self._get("details", {}) def get_activity_splits(self, _id): return self._get("splits", {}) def get_activity_hr_in_timezones(self, _id): return self._get("zones", []) def get_activity_weather(self, _id): return self._get("weather", {}) def get_activity_gear(self, _id): return self._get("gear", []) def get_activity_exercise_sets(self, _id): return self._get("sets", {}) class TestSeriesExtraction: def test_reads_each_metric_by_its_declared_index(self): details = { "metricDescriptors": descriptors( "directTimestamp", "directHeartRate", "directSpeed" ), "activityDetailMetrics": rows((1000, 120, 2.5), (2000, 130, 3.0)), } s = svc._series_from_details(details) assert s["heartRate"] == [120, 130] assert s["speed"] == [2.5, 3.0] def test_index_is_taken_from_the_descriptor_not_its_position(self): """The descriptor list order and the metrics order are not the same thing; assuming they are plots heart rate as elevation.""" details = { "metricDescriptors": [ {"key": "directHeartRate", "metricsIndex": 2}, {"key": "directElevation", "metricsIndex": 0}, ], "activityDetailMetrics": rows((500, 999, 140)), } s = svc._series_from_details(details) assert s["heartRate"] == [140] assert s["elevation"] == [500] def test_unknown_metrics_are_dropped(self): details = { "metricDescriptors": descriptors("directHeartRate", "sumMysteryField"), "activityDetailMetrics": rows((120, 7)), } assert set(svc._series_from_details(details)) == {"heartRate"} def test_all_null_column_is_dropped(self): """A column of nothing but nulls is a sensor the watch does not have, and an empty chart is worse than no chart.""" details = { "metricDescriptors": descriptors("directHeartRate", "directPower"), "activityDetailMetrics": rows((120, None), (130, None)), } s = svc._series_from_details(details) assert "heartRate" in s assert "power" not in s def test_partially_null_column_is_kept(self): details = { "metricDescriptors": descriptors("directHeartRate"), "activityDetailMetrics": rows((120,), (None,), (130,)), } assert svc._series_from_details(details)["heartRate"] == [120, None, 130] def test_short_row_does_not_raise(self): """A truncated row must not take the whole request down.""" details = { "metricDescriptors": descriptors("directHeartRate", "directSpeed"), "activityDetailMetrics": [{"metrics": [120, 2.5]}, {"metrics": [130]}], } s = svc._series_from_details(details) assert s["speed"] == [2.5, None] def test_empty_payload(self): assert svc._series_from_details({}) == {} assert svc._series_from_details({"metricDescriptors": []}) == {} def test_cadence_variants_share_one_key(self): """Running, cycling and double cadence are the same chart to a reader.""" for key in ("directRunCadence", "directBikeCadence", "directDoubleCadence"): details = { "metricDescriptors": descriptors(key), "activityDetailMetrics": rows((80,)), } assert svc._series_from_details(details)["cadence"] == [80] class TestThinning: def test_short_series_is_untouched(self): assert svc._thin([1, 2, 3], limit=10) == [1, 2, 3] def test_long_series_is_reduced_to_the_limit(self): assert len(svc._thin(list(range(5000)), limit=300)) == 300 def test_keeps_the_first_and_last_sample(self): thinned = svc._thin(list(range(1000)), limit=50) assert thinned[0] == 0 assert thinned[-1] == 999 def test_stays_in_order(self): thinned = svc._thin(list(range(1000)), limit=50) assert thinned == sorted(thinned) def test_series_are_thinned_together(self): """Thinning columns independently would pair a heart rate with another moment's speed.""" details = { "metricDescriptors": descriptors("directHeartRate", "directSpeed"), "activityDetailMetrics": rows(*[(i, i * 2) for i in range(1000)]), } s = svc._series_from_details(details) assert len(s["heartRate"]) == len(s["speed"]) assert all(sp == hr * 2 for hr, sp in zip(s["heartRate"], s["speed"])) class TestLapsAndZones: def test_laps_are_numbered_even_without_an_index(self): laps = svc._lap_rows({"lapDTOs": [{"duration": 60}, {"duration": 70}]}) assert [l["index"] for l in laps] == [1, 2] def test_lap_fields_are_carried_through(self): laps = svc._lap_rows({"lapDTOs": [{ "lapIndex": 1, "duration": 2646.0, "distance": 1130.0, "averageSpeed": 0.42, "averageHR": 105, "elevationGain": 42, }]}) assert laps[0]["distance"] == 1130.0 assert laps[0]["averageHR"] == 105 assert laps[0]["elevationGain"] == 42 def test_no_laps(self): assert svc._lap_rows({}) == [] assert svc._lap_rows(None) == [] def test_zones_are_sorted_by_number(self): zones = svc._hr_zones([ {"zoneNumber": 3, "secsInZone": 10}, {"zoneNumber": 1, "secsInZone": 30}, {"zoneNumber": 2, "secsInZone": 20}, ]) assert [z["zone"] for z in zones] == [1, 2, 3] def test_zone_with_no_time_is_kept(self): """The empty zones are part of the picture: they show the session never got hard, which is exactly what the reader is looking at.""" zones = svc._hr_zones([{"zoneNumber": 5, "secsInZone": 0}]) assert zones[0]["seconds"] == 0 def test_missing_seconds_becomes_zero_not_none(self): assert svc._hr_zones([{"zoneNumber": 1}])[0]["seconds"] == 0 class TestBuildDetail: def test_assembles_every_section(self, monkeypatch): client = FakeClient( evaluation={"activityName": "Shiyan 登山", "activityTypeDTO": {"typeKey": "mountaineering"}, "summaryDTO": {"distance": 1130.0, "duration": 2646.0}}, splits={"lapDTOs": [{"lapIndex": 1, "duration": 2646.0}]}, zones=[{"zoneNumber": 1, "secsInZone": 1383}], details={ "metricDescriptors": descriptors("directHeartRate"), "activityDetailMetrics": rows((105,)), }, ) d = svc._build_detail(client, "123") assert d["activityName"] == "Shiyan 登山" assert d["activityType"] == "mountaineering" assert d["summary"]["distance"] == 1130.0 assert d["laps"][0]["duration"] == 2646.0 assert d["hrZones"][0]["seconds"] == 1383 assert d["series"]["heartRate"] == [105] def test_one_failing_endpoint_does_not_lose_the_rest(self): """A treadmill run has no weather and no gear; those 404s must not take the page down.""" client = FakeClient( evaluation={"summaryDTO": {"duration": 600}}, weather=RuntimeError("404"), gear=RuntimeError("404"), sets=RuntimeError("404"), ) d = svc._build_detail(client, "123") assert d["summary"]["duration"] == 600 assert d["weather"] == {} assert d["gear"] == [] def test_does_not_request_more_samples_than_it_keeps(self): client = FakeClient() svc._build_detail(client, "123") assert client.maxchart <= 500 def test_does_not_fetch_the_map_polyline(self): """There is no map in the UI, and the polyline is the largest single part of the payload.""" client = FakeClient() svc._build_detail(client, "123") assert client.maxpoly == 0 class TestStoreAndRead: def test_read_returns_none_when_never_synced(self, db, user): assert svc.read_activity_detail(user["id"], "abc") is None def test_round_trip(self, db, user): svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}}) assert svc.read_activity_detail(user["id"], "abc")["summary"]["duration"] == 600 def test_corrupt_row_reads_as_missing_rather_than_raising(self, db, user): db.execute( "INSERT INTO activity_details (activity_id, user_id, payload) " "VALUES (?,?,?)", ["abc", user["id"], "{not json"] ) assert svc.read_activity_detail(user["id"], "abc") is None def test_details_are_per_account(self, db, user, make_user): other = make_user("b@example.com") svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}}) assert svc.read_activity_detail(other["id"], "abc") is None def test_storing_again_replaces(self, db, user): svc._store_detail(user["id"], "abc", {"summary": {"duration": 1}}) svc._store_detail(user["id"], "abc", {"summary": {"duration": 2}}) assert svc.read_activity_detail(user["id"], "abc")["summary"]["duration"] == 2 class TestSyncActivityDetails: def seed(self, db, user, ids): for i, aid in enumerate(ids): stamp = f"2026-08-{10 + i:02d}T08:00:00" db.execute( "INSERT INTO activities (id, user_id, activity_type, start_time, " "end_time) VALUES (?,?,?,?,?)", [aid, user["id"], "running", stamp, stamp], ) def test_fetches_every_activity_without_a_detail(self, db, user): self.seed(db, user, ["a1", "a2", "a3"]) client = FakeClient(evaluation={"summaryDTO": {"duration": 600}}) assert svc.sync_activity_details(client, user["id"]) == 3 assert svc.read_activity_detail(user["id"], "a2") is not None def test_skips_activities_that_already_have_one(self, db, user): self.seed(db, user, ["a1", "a2"]) svc._store_detail(user["id"], "a1", {"summary": {}}) client = FakeClient(evaluation={"summaryDTO": {"duration": 600}}) assert svc.sync_activity_details(client, user["id"]) == 1 def test_is_a_no_op_once_everything_is_stored(self, db, user): self.seed(db, user, ["a1"]) client = FakeClient(evaluation={"summaryDTO": {"duration": 600}}) svc.sync_activity_details(client, user["id"]) assert svc.sync_activity_details(client, user["id"]) == 0 def test_limit_caps_the_batch(self, db, user): self.seed(db, user, ["a1", "a2", "a3", "a4"]) client = FakeClient(evaluation={"summaryDTO": {"duration": 600}}) assert svc.sync_activity_details(client, user["id"], limit=2) == 2 def test_newest_activities_are_fetched_first(self, db, user): """A backfill that runs for minutes should make the activities the user is most likely to open available first.""" self.seed(db, user, ["old", "new"]) # start_time ascends with index client = FakeClient(evaluation={"summaryDTO": {"duration": 600}}) svc.sync_activity_details(client, user["id"], limit=1) assert svc.read_activity_detail(user["id"], "new") is not None assert svc.read_activity_detail(user["id"], "old") is None def test_one_failing_activity_does_not_stop_the_rest(self, db, user): self.seed(db, user, ["a1", "a2", "a3"]) calls = {"n": 0} def flaky(_client, activity_id): calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("Garmin 挂了") return {"activityId": activity_id, "summary": {}} import services.garmin as g original = g._build_detail g._build_detail = flaky try: assert svc.sync_activity_details(FakeClient(), user["id"]) == 2 finally: g._build_detail = original def test_only_this_accounts_activities(self, db, user, make_user): other = make_user("b@example.com") self.seed(db, user, ["mine"]) db.execute( "INSERT INTO activities (id, user_id, activity_type, start_time, end_time) " "VALUES (?,?,?,?,?)", ["theirs", other["id"], "running", "2026-08-11T08:00:00", "2026-08-11T09:00:00"], ) fake = FakeClient(evaluation={"summaryDTO": {"duration": 600}}) assert svc.sync_activity_details(fake, user["id"]) == 1 assert svc.read_activity_detail(other["id"], "theirs") is None def test_progress_is_reported(self, db, user): self.seed(db, user, ["a1", "a2"]) seen = [] svc.sync_activity_details( FakeClient(), user["id"], on_progress=lambda d, t: seen.append((d, t)) ) assert seen == [(1, 2), (2, 2)] class TestEndpoint: def test_requires_auth(self, client): assert client.get("/api/garmin/activities/1/detail").status_code == 401 def test_unsynced_activity_says_so(self, client, auth): r = client.get("/api/garmin/activities/nope/detail", headers=auth) assert r.status_code == 404 body = r.get_json() assert body["needsSync"] is True assert "同步" in body["error"] def test_returns_the_stored_detail(self, client, auth, user, db): svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}}) body = client.get("/api/garmin/activities/abc/detail", headers=auth).get_json() assert body["summary"]["duration"] == 600 def test_reading_never_touches_garmin(self, client, auth, user, db, monkeypatch): """The whole point of storing details during the sync: a tap must not depend on Garmin being reachable.""" svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}}) def explode(*a, **k): raise AssertionError("opening an activity must be a local read") monkeypatch.setattr(svc, "_connect", explode) assert client.get( "/api/garmin/activities/abc/detail", headers=auth ).status_code == 200 def test_backfill_needs_a_bound_account(self, client, auth): r = client.post("/api/garmin/sync-details", headers=auth, json={}) assert r.status_code == 400 def test_backfill_status_is_readable(self, client, auth): body = client.get("/api/garmin/sync-details", headers=auth).get_json() assert body["running"] is False