按需回源是错的:点一次运动要等七个 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>
251 lines
10 KiB
Python
251 lines
10 KiB
Python
"""
|
|
Profile, units and sync preferences.
|
|
|
|
The interesting behaviour is the validation boundary: a body measurement that
|
|
is out of range is a user mistake and must be rejected with a message they can
|
|
act on, while an off-list picker value is a stale client and must be snapped
|
|
rather than refused.
|
|
"""
|
|
import datetime
|
|
|
|
import pytest
|
|
|
|
from services import settings as svc
|
|
|
|
|
|
class TestDefaults:
|
|
def test_unset_account_gets_defaults(self, db, user):
|
|
s = svc.get_settings(user["id"])
|
|
assert s["units"] == "metric"
|
|
assert s["autoSync"] is True
|
|
assert s["autoSyncMinutes"] == 60
|
|
assert s["historyDays"] == 365
|
|
|
|
def test_body_fields_start_empty(self, db, user):
|
|
s = svc.get_settings(user["id"])
|
|
assert s["heightCm"] is None
|
|
assert s["weightKg"] is None
|
|
assert s["birthDate"] is None
|
|
assert s["sex"] is None
|
|
|
|
def test_derived_fields_are_none_without_inputs(self, db, user):
|
|
s = svc.get_settings(user["id"])
|
|
assert s["age"] is None
|
|
assert s["bmi"] is None
|
|
|
|
|
|
class TestSaving:
|
|
def test_round_trip(self, db, user):
|
|
svc.save_settings(user["id"], {"heightCm": 178, "weightKg": 72})
|
|
s = svc.get_settings(user["id"])
|
|
assert s["heightCm"] == 178
|
|
assert s["weightKg"] == 72
|
|
|
|
def test_partial_patch_leaves_the_rest_alone(self, db, user):
|
|
svc.save_settings(user["id"], {"heightCm": 178, "sex": "male"})
|
|
svc.save_settings(user["id"], {"weightKg": 70})
|
|
s = svc.get_settings(user["id"])
|
|
assert s["heightCm"] == 178, "an unrelated field must survive a patch"
|
|
assert s["sex"] == "male"
|
|
assert s["weightKg"] == 70
|
|
|
|
def test_clearing_a_field(self, db, user):
|
|
svc.save_settings(user["id"], {"heightCm": 178})
|
|
svc.save_settings(user["id"], {"heightCm": None})
|
|
assert svc.get_settings(user["id"])["heightCm"] is None
|
|
|
|
def test_unknown_keys_are_ignored_not_rejected(self, db, user):
|
|
"""An older or newer client posting a field we do not know about must
|
|
still save the fields we do."""
|
|
svc.save_settings(user["id"], {"heightCm": 170, "favouriteColour": "blue"})
|
|
assert svc.get_settings(user["id"])["heightCm"] == 170
|
|
|
|
def test_empty_patch_is_a_no_op(self, db, user):
|
|
svc.save_settings(user["id"], {"heightCm": 180})
|
|
svc.save_settings(user["id"], {})
|
|
assert svc.get_settings(user["id"])["heightCm"] == 180
|
|
|
|
def test_settings_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.save_settings(user["id"], {"heightCm": 178})
|
|
svc.save_settings(other["id"], {"heightCm": 160})
|
|
assert svc.get_settings(user["id"])["heightCm"] == 178
|
|
assert svc.get_settings(other["id"])["heightCm"] == 160
|
|
|
|
|
|
class TestValidation:
|
|
@pytest.mark.parametrize("value", [79, 251, -5, 0])
|
|
def test_height_out_of_range(self, db, user, value):
|
|
with pytest.raises(svc.InvalidSetting):
|
|
svc.save_settings(user["id"], {"heightCm": value})
|
|
|
|
@pytest.mark.parametrize("value", [24, 301])
|
|
def test_weight_out_of_range(self, db, user, value):
|
|
with pytest.raises(svc.InvalidSetting):
|
|
svc.save_settings(user["id"], {"weightKg": value})
|
|
|
|
def test_non_numeric_height(self, db, user):
|
|
with pytest.raises(svc.InvalidSetting):
|
|
svc.save_settings(user["id"], {"heightCm": "tall"})
|
|
|
|
def test_message_names_the_field(self, db, user):
|
|
with pytest.raises(svc.InvalidSetting) as e:
|
|
svc.save_settings(user["id"], {"heightCm": 500})
|
|
assert "身高" in str(e.value)
|
|
|
|
def test_birth_date_must_be_a_date(self, db, user):
|
|
with pytest.raises(svc.InvalidSetting):
|
|
svc.save_settings(user["id"], {"birthDate": "1990/05/04"})
|
|
|
|
def test_birth_date_cannot_be_in_the_future(self, db, user):
|
|
future = (datetime.date.today() + datetime.timedelta(days=1)).isoformat()
|
|
with pytest.raises(svc.InvalidSetting):
|
|
svc.save_settings(user["id"], {"birthDate": future})
|
|
|
|
def test_birth_date_cannot_be_absurdly_old(self, db, user):
|
|
with pytest.raises(svc.InvalidSetting):
|
|
svc.save_settings(user["id"], {"birthDate": "1850-01-01"})
|
|
|
|
def test_unknown_sex_rejected(self, db, user):
|
|
with pytest.raises(svc.InvalidSetting):
|
|
svc.save_settings(user["id"], {"sex": "yes"})
|
|
|
|
def test_unknown_units_rejected(self, db, user):
|
|
with pytest.raises(svc.InvalidSetting):
|
|
svc.save_settings(user["id"], {"units": "furlongs"})
|
|
|
|
|
|
class TestSnapping:
|
|
"""Picker values come from a list the backend published, so an off-list
|
|
value means a stale client — snap it rather than fail the whole save."""
|
|
|
|
def test_off_list_interval_snaps_to_nearest(self, db, user):
|
|
svc.save_settings(user["id"], {"autoSyncMinutes": 45})
|
|
assert svc.get_settings(user["id"])["autoSyncMinutes"] in svc.INTERVALS
|
|
|
|
def test_snaps_to_the_actual_nearest(self, db, user):
|
|
svc.save_settings(user["id"], {"autoSyncMinutes": 200})
|
|
assert svc.get_settings(user["id"])["autoSyncMinutes"] == 180
|
|
|
|
def test_history_zero_means_everything(self, db, user):
|
|
svc.save_settings(user["id"], {"historyDays": 0})
|
|
assert svc.get_settings(user["id"])["historyDays"] == 0
|
|
|
|
def test_negative_history_becomes_everything(self, db, user):
|
|
svc.save_settings(user["id"], {"historyDays": -30})
|
|
assert svc.get_settings(user["id"])["historyDays"] == 0
|
|
|
|
def test_garbage_interval_falls_back_to_default(self, db, user):
|
|
svc.save_settings(user["id"], {"autoSyncMinutes": "soon"})
|
|
assert svc.get_settings(user["id"])["autoSyncMinutes"] == 60
|
|
|
|
|
|
class TestDerived:
|
|
def test_age_from_birth_date(self, db, user):
|
|
born = datetime.date.today().replace(year=datetime.date.today().year - 30)
|
|
svc.save_settings(user["id"], {"birthDate": born.isoformat()})
|
|
assert svc.get_settings(user["id"])["age"] == 30
|
|
|
|
def test_age_on_the_day_before_a_birthday(self, monkeypatch):
|
|
real = datetime.date
|
|
|
|
class Frozen(real):
|
|
@classmethod
|
|
def today(cls):
|
|
return real(2026, 12, 24)
|
|
|
|
monkeypatch.setattr(svc.datetime, "date", Frozen)
|
|
assert svc.age_from("1990-12-25") == 35
|
|
|
|
def test_age_on_the_birthday(self, monkeypatch):
|
|
real = datetime.date
|
|
|
|
class Frozen(real):
|
|
@classmethod
|
|
def today(cls):
|
|
return real(2026, 12, 25)
|
|
|
|
monkeypatch.setattr(svc.datetime, "date", Frozen)
|
|
assert svc.age_from("1990-12-25") == 36
|
|
|
|
def test_bmi(self):
|
|
assert svc.bmi_from(178, 72) == 22.7
|
|
|
|
def test_bmi_needs_both(self):
|
|
assert svc.bmi_from(178, None) is None
|
|
assert svc.bmi_from(None, 72) is None
|
|
|
|
def test_bmi_appears_in_settings(self, db, user):
|
|
svc.save_settings(user["id"], {"heightCm": 180, "weightKg": 81})
|
|
assert svc.get_settings(user["id"])["bmi"] == 25.0
|
|
|
|
|
|
class TestEndpoints:
|
|
def test_get_requires_auth(self, client):
|
|
assert client.get("/api/settings").status_code == 401
|
|
|
|
def test_put_requires_auth(self, client):
|
|
assert client.put("/api/settings", json={}).status_code == 401
|
|
|
|
def test_get_returns_defaults(self, client, auth):
|
|
body = client.get("/api/settings", headers=auth).get_json()
|
|
assert body["units"] == "metric"
|
|
|
|
def test_put_saves_and_returns_the_new_state(self, client, auth):
|
|
body = client.put(
|
|
"/api/settings", headers=auth, json={"heightCm": 175, "sex": "female"}
|
|
).get_json()
|
|
assert body["heightCm"] == 175
|
|
assert body["sex"] == "female"
|
|
|
|
def test_put_rejects_bad_input_with_400(self, client, auth):
|
|
r = client.put("/api/settings", headers=auth, json={"weightKg": 999})
|
|
assert r.status_code == 400
|
|
assert "体重" in r.get_json()["error"]
|
|
|
|
def test_options_lists_only_acceptable_values(self, client, auth):
|
|
opts = client.get("/api/settings/options", headers=auth).get_json()
|
|
assert set(opts["sexes"]) == set(svc.SEXES)
|
|
assert set(opts["autoSyncMinutes"]) == set(svc.INTERVALS)
|
|
|
|
def test_every_offered_option_is_actually_accepted(self, client, auth):
|
|
"""The picker must never offer something the validator would reject."""
|
|
opts = client.get("/api/settings/options", headers=auth).get_json()
|
|
for sex in opts["sexes"]:
|
|
assert client.put("/api/settings", headers=auth,
|
|
json={"sex": sex}).status_code == 200
|
|
for unit in opts["units"]:
|
|
assert client.put("/api/settings", headers=auth,
|
|
json={"units": unit}).status_code == 200
|
|
for minutes in opts["autoSyncMinutes"]:
|
|
r = client.put("/api/settings", headers=auth,
|
|
json={"autoSyncMinutes": minutes})
|
|
assert r.get_json()["autoSyncMinutes"] == minutes
|
|
for days in opts["historyDays"]:
|
|
r = client.put("/api/settings", headers=auth, json={"historyDays": days})
|
|
assert r.get_json()["historyDays"] == days
|
|
|
|
|
|
class TestRatingBasis:
|
|
def test_requires_auth(self, client):
|
|
assert client.get("/api/settings/rating-basis").status_code == 401
|
|
|
|
def test_lists_a_source_for_every_band(self, client, auth):
|
|
body = client.get("/api/settings/rating-basis", headers=auth).get_json()
|
|
assert body["bands"], "the screen would be empty"
|
|
for entry in body["bands"]:
|
|
assert entry["metric"] and entry["bands"] and entry["source"], entry
|
|
|
|
def test_includes_the_fitness_age_method(self, client, auth):
|
|
body = client.get("/api/settings/rating-basis", headers=auth).get_json()
|
|
assert body["fitnessAge"]["steps"]
|
|
assert body["fitnessAge"]["caveat"]
|
|
|
|
def test_states_that_ai_does_not_set_thresholds(self, client, auth):
|
|
body = client.get("/api/settings/rating-basis", headers=auth).get_json()
|
|
assert "AI" in body["note"]
|