Files
GarminHealthLab/backend/tests/conftest.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

142 lines
4.1 KiB
Python

"""
Shared pytest fixtures.
Environment must be configured BEFORE `config` is imported, because config.py
reads os.environ at import time. Each test then gets its own SQLite file so
tests never share state.
"""
import os
import sys
import tempfile
import pytest
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _BACKEND_DIR)
os.environ.setdefault("DB_TYPE", "sqlite")
os.environ.setdefault("JWT_SECRET", "test_secret_at_least_32_bytes_long_ok")
os.environ.setdefault("CORS_ORIGIN", "http://localhost:3000")
# Point at a throwaway path; the db_path fixture overrides it per test.
os.environ.setdefault(
"DATABASE_PATH", os.path.join(tempfile.mkdtemp(), "bootstrap.db")
)
import db as db_module # noqa: E402
from app import create_app # noqa: E402
# config.py calls load_dotenv() at import, so backend/.env leaks into the test
# process — a developer's real AI_MODEL_CHAIN or API keys would silently change
# what the suite exercises (and could bill real API calls). Clear them here;
# individual tests opt back in through the `keys` / `gateway` fixtures.
_AI_ENV_VARS = (
"AI_MODEL_CHAIN",
"AI_DAY_BUDGET",
"AI_TIMEOUT_SECONDS",
"GEMINI_API_KEY",
"NVIDIA_API_KEY",
"NVIDIA_BASE_URL",
"AI_GATEWAY_TOKEN",
"AI_GATEWAY_BASE_URL",
"AI_GATEWAY_MODEL",
"OLLAMA_BASE_URL",
"OLLAMA_MODEL",
)
@pytest.fixture(autouse=True)
def _isolate_ai_env(monkeypatch):
for var in _AI_ENV_VARS:
monkeypatch.delenv(var, raising=False)
# Most tests need to create users freely; the production default closes
# registration once one account exists. test_registration_policy.py clears
# this to exercise the real default.
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
@pytest.fixture(autouse=True)
def _clear_garmin_client_cache():
"""Drop cached Garmin sessions between tests.
`_connect` keeps an authenticated client per user for 15 minutes, so a
stub installed by one test would otherwise be handed to the next one —
and a test that expects `_connect` to be called would see it skipped.
"""
from services import garmin as garmin_svc
garmin_svc._clients.clear()
yield
garmin_svc._clients.clear()
@pytest.fixture
def db(tmp_path, monkeypatch):
"""A freshly initialized, isolated SQLite database for one test."""
path = str(tmp_path / "test.db")
monkeypatch.setattr(db_module, "SQLITE_PATH", path)
db_module.init_db()
return db_module
@pytest.fixture
def app(db):
application = create_app()
application.config.update(TESTING=True)
return application
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(client):
"""A registered user: returns {id, email, token, password}."""
password = "secret123"
resp = client.post(
"/api/auth/register",
json={
"email": "tester@example.com",
"garminEmail": "gm@example.com",
"garminPassword": password,
},
)
assert resp.status_code == 201, resp.get_data(as_text=True)
body = resp.get_json()
return {**body, "password": password}
@pytest.fixture
def auth(user):
"""Authorization headers for the registered user."""
return {"Authorization": f"Bearer {user['token']}"}
@pytest.fixture
def seed_health(db, user):
"""Insert daily health rows. Returns the inserted records."""
def _seed(records):
for r in records:
db.execute(
"INSERT INTO health_data (id, user_id, date, steps, heart_rate, "
"heart_rate_variability, sleep_duration, sleep_quality, stress, "
"calories_burned) VALUES (?,?,?,?,?,?,?,?,?,?)",
[
f"{user['id']}-{r['date']}",
user["id"],
r["date"],
r.get("steps"),
r.get("heart_rate"),
r.get("hrv"),
r.get("sleep_duration"),
r.get("sleep_quality"),
r.get("stress"),
r.get("calories"),
],
)
return records
return _seed