按需回源是错的:点一次运动要等七个 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>
399 lines
16 KiB
Python
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
|