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>
This commit is contained in:
ericwyuan
2026-08-24 04:03:49 +08:00
parent fa865ca8a6
commit 34940cc387
16 changed files with 1218 additions and 69 deletions

View File

@@ -0,0 +1,253 @@
"""
身体年龄.
This is the one calculation in the app that produces a health verdict out of
thin air, so the properties that matter are: it never invents a number from
missing inputs, it moves in the right direction, and it says what it did.
"""
import pytest
from services import fitness_age as fa
def est(**kwargs):
base = {"age": 36, "sex": "male", "vo2max": 40,
"resting_hr": 60, "bmi": 22.0}
base.update(kwargs)
return fa.estimate(**base)
class TestMissingInputs:
def test_no_age(self):
r = est(age=None)
assert r["value"] is None
assert "出生日期" in r["missing"]
def test_no_sex(self):
r = est(sex=None)
assert r["value"] is None
assert "性别" in r["missing"]
def test_unknown_sex_is_treated_as_missing(self):
"""'other' has no reference table; guessing one would be worse than
saying the estimate cannot be made."""
r = est(sex="other")
assert r["value"] is None
assert "性别" in r["missing"]
def test_no_vo2max(self):
r = est(vo2max=None)
assert r["value"] is None
assert any("VO₂max" in m for m in r["missing"])
def test_missing_message_explains_how_to_get_vo2max(self):
r = est(vo2max=None)
assert "跑步" in " ".join(r["missing"])
def test_all_missing_are_listed_at_once(self):
r = est(age=None, sex=None, vo2max=None)
assert len(r["missing"]) == 3, "the user should fix everything in one go"
def test_basis_is_returned_even_when_the_estimate_cannot_be_made(self):
assert est(age=None)["basis"]["steps"]
def test_optional_inputs_are_genuinely_optional(self):
r = est(resting_hr=None, bmi=None)
assert r["value"] is not None
class TestDirection:
def test_higher_vo2max_gives_a_younger_body_age(self):
assert est(vo2max=45)["value"] < est(vo2max=33)["value"]
def test_lower_resting_hr_gives_a_younger_body_age(self):
assert est(resting_hr=48)["value"] < est(resting_hr=78)["value"]
def test_bmi_outside_the_healthy_band_never_helps(self):
healthy = est(bmi=22)["value"]
assert est(bmi=31)["value"] >= healthy
assert est(bmi=16)["value"] >= healthy
def test_bmi_inside_the_band_does_not_move_it(self):
assert est(bmi=18.5)["value"] == est(bmi=24.9)["value"] == est(bmi=22)["value"]
def test_women_need_less_vo2max_for_the_same_body_age(self):
"""The reference distributions differ by sex; using one table for both
would systematically flatter men and penalise women."""
assert est(sex="female", vo2max=37)["value"] < est(sex="male", vo2max=37)["value"]
def test_monotonic_across_the_whole_range(self):
values = [est(vo2max=v)["value"] for v in range(25, 55)]
assert values == sorted(values, reverse=True), values
class TestBounds:
def test_never_exceeds_the_deviation_cap_downwards(self):
r = est(age=60, vo2max=60, resting_hr=40, bmi=22)
assert r["value"] >= 60 - fa.MAX_DEVIATION
def test_never_exceeds_the_deviation_cap_upwards(self):
r = est(age=30, vo2max=15, resting_hr=95, bmi=40)
assert r["value"] <= 30 + fa.MAX_DEVIATION
def test_floor(self):
assert est(age=25, vo2max=70)["value"] >= fa.AGE_FLOOR
def test_ceiling(self):
assert est(age=84, vo2max=12, resting_hr=100, bmi=45)["value"] <= fa.AGE_CEILING
def test_clamping_is_disclosed(self):
"""A clamped number is not the raw result, and the UI says so — that
only works if the flag is set."""
assert est(age=60, vo2max=75, resting_hr=40)["clamped"] is True
def test_an_ordinary_result_is_not_flagged_as_clamped(self):
assert est(age=40, vo2max=37, resting_hr=60, bmi=22)["clamped"] is False
def test_rhr_adjustment_is_capped(self):
"""Without the cap a very high resting heart rate alone would drive the
whole estimate."""
moderate = est(resting_hr=90)["value"]
extreme = est(resting_hr=200)["value"]
assert extreme - moderate <= 2
def test_bmi_adjustment_is_capped(self):
assert est(bmi=60)["value"] - est(bmi=32)["value"] <= 3
class TestExtrapolation:
def test_above_the_youngest_reference_row_keeps_extrapolating(self):
"""A hard floor here put everyone fitter than the median 25-year-old at
exactly the same body age — a cliff right where this app's users sit."""
a = fa._interpolate_age(46, fa.VO2_MEDIAN["male"])
b = fa._interpolate_age(50, fa.VO2_MEDIAN["male"])
assert a > b, "a fitter reading must still move the number"
def test_below_the_oldest_row_keeps_extrapolating(self):
old = fa._interpolate_age(20, fa.VO2_MEDIAN["male"])
assert old > fa.VO2_MEDIAN["male"][-1][0]
def test_interpolates_between_rows(self):
table = fa.VO2_MEDIAN["male"]
# 42.5 sits midway between the 25-year (44) and 35-year (41) rows.
age = fa._interpolate_age(42.5, table)
assert 29 < age < 31
def test_reference_tables_decrease_with_age(self):
for sex, table in fa.VO2_MEDIAN.items():
ages = [a for a, _ in table]
vo2s = [v for _, v in table]
assert ages == sorted(ages), sex
assert vo2s == sorted(vo2s, reverse=True), sex
class TestDamping:
"""Individual VO2max spread (SD ~7) dwarfs the age decline (~0.35/yr), so
an undamped estimate reads 20 for anyone reasonably fit — which is what it
did, and why a 34-year-old with VO2max 46 was told he was 21."""
def test_a_fit_thirtysomething_is_not_told_he_is_twenty(self):
r = fa.estimate(age=34, sex="male", vo2max=46, resting_hr=62, bmi=26.0)
assert r["value"] >= 25, r
def test_damping_pulls_towards_the_real_age(self):
r = est(age=34, vo2max=46)
base = r["steps"][0]
assert base["raw"] < base["years"] < 34
def test_the_raw_value_is_still_reported(self):
"""Damping is a choice, so the undamped figure stays visible rather
than being quietly replaced."""
base = est()["steps"][0]
assert "raw" in base and "damping" in base
def test_an_average_person_lands_near_their_real_age(self):
r = fa.estimate(age=35, sex="male", vo2max=41, resting_hr=60, bmi=22)
assert abs(r["delta"]) <= 1, r
def test_damping_does_not_flip_the_direction(self):
assert est(vo2max=48)["value"] < est(vo2max=32)["value"]
def test_basis_discloses_the_damping_factor(self):
step = fa.BASIS["steps"][0]
assert "阻尼" in step["detail"]
class TestTransparency:
def test_every_step_is_reported(self):
r = est()
labels = [s["label"] for s in r["steps"]]
assert "VO₂max 基准" in labels
assert "静息心率" in labels
assert "BMI" in labels
def test_steps_carry_the_input_they_used(self):
r = est(resting_hr=59)
hr = next(s for s in r["steps"] if s["label"] == "静息心率")
assert "59" in hr["input"]
def test_omitted_inputs_produce_no_step(self):
r = est(resting_hr=None, bmi=None)
assert [s["label"] for s in r["steps"]] == ["VO₂max 基准"]
def test_delta_matches_the_reported_ages(self):
r = est(age=36, vo2max=42)
assert r["delta"] == r["value"] - r["chronologicalAge"]
def test_basis_names_a_source_for_every_step(self):
for step in fa.BASIS["steps"]:
assert step["source"], step
def test_basis_states_this_is_not_garmins_number(self):
assert "Garmin" in fa.BASIS["summary"]
def test_basis_carries_a_medical_caveat(self):
assert "" in fa.BASIS["caveat"]
def test_basis_constants_match_the_code(self):
"""The prose describes the arithmetic; if someone tunes a constant the
description must not silently keep the old number."""
rhr = next(s for s in fa.BASIS["steps"] if "心率" in s["name"])
assert f"{fa.RHR_REFERENCE:.0f}" in rhr["detail"]
bmi = next(s for s in fa.BASIS["steps"] if "BMI" in s["name"])
assert str(fa.BMI_LOW) in bmi["detail"] and str(fa.BMI_HIGH) in bmi["detail"]
class TestEndpoint:
def test_requires_auth(self, client):
assert client.get("/api/health/fitness-age").status_code == 401
def test_reports_what_is_missing_for_an_empty_profile(self, client, auth):
body = client.get("/api/health/fitness-age", headers=auth).get_json()
assert body["value"] is None
assert body["missing"]
def test_uses_the_saved_profile_and_the_latest_readings(
self, client, auth, user, db, seed_health
):
client.put("/api/settings", headers=auth,
json={"birthDate": "1990-05-04", "sex": "male",
"heightCm": 178, "weightKg": 72})
seed_health([{"date": "2026-08-20", "heart_rate": 58}])
db.execute(
"UPDATE health_data SET vo2max = ? WHERE user_id = ?", [42, user["id"]]
)
body = client.get("/api/health/fitness-age", headers=auth).get_json()
assert body["value"] is not None, body
assert body["chronologicalAge"] == 36
def test_falls_back_to_an_older_vo2max_reading(
self, client, auth, user, db, seed_health
):
"""VO2max only refreshes after an outdoor run, so the last recorded
value is still the current one."""
client.put("/api/settings", headers=auth,
json={"birthDate": "1990-05-04", "sex": "male"})
seed_health([{"date": "2026-06-01"}, {"date": "2026-08-20"}])
db.execute(
"UPDATE health_data SET vo2max = ? WHERE user_id = ? AND date = ?",
[44, user["id"], "2026-06-01"],
)
body = client.get("/api/health/fitness-age", headers=auth).get_json()
assert body["value"] is not None, "an old VO2max still counts"