Files
GarminHealthLab/backend/tests/test_activity_detail.py
ericwyuan 34940cc387 feat(sync): 运动详情改为同步入库,详情页只读本地
按需回源是错的:点一次运动要等七个 Garmin 接口,网络好的时候慢,
网络差的时候直接超时(实测公网下 Network Error)。

- sync_data 顺带补齐缺详情的运动
- POST /api/garmin/sync-details 后台补齐存量,GET 查进度
- 详情页只读本地库;没有就提示去同步,不再回源
- 同步页新增「补齐运动详情」按钮,带进度

身体年龄:加入公开的阻尼系数
- 34 岁 VO₂max 46 原本算出 21 岁。不是算错,是方法本身会饱和:
  人与人之间的 VO₂max 标准差约 7,而年龄每年只带来约 0.35 的衰减,
  于是稍微能练的人都会撞到参考表最年轻一档。
- 按 50% 向实际年龄收拢,收敛范围 ±20 → ±12 岁,同一算例现在给 27 岁。
- 去掉「高于最年轻一档按 20 岁计」的硬地板,那是一道正好落在用户身上的悬崖。
- 界面同时显示未收拢的原始值,阻尼系数写进评分依据。

路由:为每个路径补无斜杠别名
- F7 写地址栏时去掉尾斜杠,于是 /daily/ 在地址栏是 /daily,
  而那个地址匹配不到任何路由,刷新或分享就落到「找不到页面」。

布局:让页面结构上无法被撑宽
- 网格改用 minmax(min(210px,100%),1fr):裸的 minmax(210px,1fr) 允许
  两列加起来超过窄屏宽度,第二张卡就被切掉在屏幕外。
- .ring-row 用 minmax(0,1fr),1fr 会以 min-content 兜底,一句长说明就能
  把整行顶宽。
- .page-inner 加 overflow-x: clip。
- html/body 用 100dvh:手机浏览器把自己的地址栏盖在布局视口上,
  100% 高的应用会把底部 Tab 栏顶到它们下面——对用户来说就是没有 Tab 栏。

测试:新增 122 项(设置 44、身体年龄 44、运动详情 42),全量 446 项通过。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 04:03:49 +08:00

399 lines
16 KiB
Python

"""
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, client):
other = client.post(
"/api/auth/register",
json={"email": "b@example.com", "garminEmail": "bg@example.com",
"garminPassword": "pw123456"},
).get_json()
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, client):
other = client.post(
"/api/auth/register",
json={"email": "b@example.com", "garminEmail": "bg@example.com",
"garminPassword": "pw123456"},
).get_json()
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