Add running fitness history and configurable heart rate zones
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from math import exp
|
||||
from types import SimpleNamespace
|
||||
|
||||
from test_app import login
|
||||
|
||||
from app.extensions import db
|
||||
from app.fitness import effort, history
|
||||
from app.heart_rate import zones
|
||||
from app.models import Activity, User
|
||||
|
||||
pytest_plugins = ("test_app",)
|
||||
|
||||
|
||||
def run(seconds=600, histogram=None):
|
||||
return SimpleNamespace(
|
||||
sport="running",
|
||||
duration_s=seconds,
|
||||
analysis_summary={"intervals": [{"hr_seconds": histogram or {}}]},
|
||||
)
|
||||
|
||||
|
||||
def test_effort_boundaries_weights_and_coverage():
|
||||
scored = effort(run(histogram={"120": 200, "140": 200, "160": 200}), [140, 160])
|
||||
assert scored["score"] == 20
|
||||
assert scored["coverage"] == 100
|
||||
assert effort(run(histogram={"140": 479}), [140, 160])["score"] is None
|
||||
assert effort(run(histogram={"140": 480}), [140, 160])["score"] == 16
|
||||
assert effort(run(histogram={"140": 600}), None)["score"] is None
|
||||
assert effort(run(histogram={"140": 600}), None)["coverage"] == 100
|
||||
assert effort(run(histogram={"250": 600}), [140, 160])["score"] is None
|
||||
five = effort(
|
||||
run(histogram={"121": 120, "122": 120, "152": 120, "167": 120, "181": 120}),
|
||||
[122, 152, 167, 181],
|
||||
)
|
||||
assert five["score"] == 30
|
||||
assert five["zone_minutes"] == [2, 2, 2, 2, 2]
|
||||
|
||||
|
||||
def test_automatic_zones_update_on_birthday_manual_overrides():
|
||||
user = SimpleNamespace(
|
||||
timezone="Australia/Sydney", hr_zones={"mode": "auto", "birthday": "1990-10-22"}
|
||||
)
|
||||
assert zones(user, date(2026, 10, 21))["max_hr"] == 185
|
||||
assert zones(user, date(2026, 10, 22))["max_hr"] == 184
|
||||
user.hr_zones = {"mode": "max", "max_hr": 195, "birthday": "1990-10-22"}
|
||||
assert zones(user, date(2026, 10, 22))["max_hr"] == 195
|
||||
user.hr_zones = None
|
||||
assert zones(user)["max_hr"] == 190
|
||||
user.hr_zones = {"mode": "custom", "boundaries": [122, 152, 167, 181]}
|
||||
assert zones(user)["labels"] == ["0–121", "122–151", "152–166", "167–180", "181+"]
|
||||
|
||||
|
||||
def test_history_local_days_sum_decay_missing_and_owner_scope(app):
|
||||
with app.app_context():
|
||||
db.session.delete(db.session.get(Activity, 1))
|
||||
user = db.session.get(User, 1)
|
||||
user.hr_zones = {"mode": "custom", "boundaries": [140, 160, 180, 200]}
|
||||
for key, owner, hour, histogram in [
|
||||
("a", 1, 13, {"140": 600}),
|
||||
("b", 1, 14, {"140": 600}),
|
||||
("other", 2, 14, {"160": 600}),
|
||||
("missing", 1, 15, {}),
|
||||
]:
|
||||
row = Activity(
|
||||
owner_id=owner,
|
||||
source_key=key,
|
||||
title=key,
|
||||
sport="running",
|
||||
started_at=datetime(2026, 1, 1, hour, tzinfo=UTC),
|
||||
duration_s=600,
|
||||
analysis_summary=run(histogram=histogram).analysis_summary,
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
data = history(user, date(2026, 1, 3))
|
||||
assert len(data["runs"]) == 3
|
||||
assert data["series"][0]["day"] == "2026-01-02"
|
||||
first, second = data["series"]
|
||||
assert first["load"] is None
|
||||
assert first["missing"] == 1
|
||||
assert first["fitness"] == round(40 * (1 - exp(-1 / 42)), 2)
|
||||
assert first["fatigue"] == round(40 * (1 - exp(-1 / 7)), 2)
|
||||
assert first["form"] == round(40 * (exp(-1 / 7) - exp(-1 / 42)), 2)
|
||||
assert second["load"] == 0
|
||||
assert second["fitness"] == round(40 * (1 - exp(-1 / 42)) * exp(-1 / 42), 2)
|
||||
assert second["provisional"]
|
||||
assert history(user, date(2026, 1, 3)) == data
|
||||
user.hr_zones = {"mode": "custom", "boundaries": [160, 180, 200, 220]}
|
||||
recalculated = history(user, date(2026, 1, 3))
|
||||
assert recalculated["series"][0]["fitness"] == round(20 * (1 - exp(-1 / 42)), 2)
|
||||
assert history(user, date(2026, 1, 1))["series"] == []
|
||||
|
||||
|
||||
def test_fitness_page_private_and_renders_history(app):
|
||||
client = app.test_client()
|
||||
assert client.get("/fitness").status_code == 302
|
||||
assert client.get("/fitness/data").status_code == 302
|
||||
login(client)
|
||||
response = client.get("/fitness")
|
||||
assert response.status_code == 200
|
||||
assert len(client.get("/fitness/data").json["runs"]) == 1
|
||||
assert b"A quiet morning run" in response.data
|
||||
with app.app_context():
|
||||
db.session.get(User, 1).hr_bands = [140, 160]
|
||||
db.session.commit()
|
||||
assert b"fitness-chart" in client.get("/fitness").data
|
||||
assert b"Run effort:" in client.get("/activities/1").data
|
||||
|
||||
|
||||
def test_zone_settings_validation_persistence_and_privacy(app):
|
||||
client = app.test_client()
|
||||
assert client.post("/training/zones", data={"mode": "auto"}).status_code == 302
|
||||
login(client)
|
||||
valid = {"mode": "custom", "z2": "122", "z3": "152", "z4": "167", "z5": "181"}
|
||||
assert client.post("/training/zones", data=valid).status_code == 302
|
||||
assert client.post("/training/zones", data={**valid, "z3": "100"}).status_code == 400
|
||||
assert client.post("/training/zones", data={"mode": "max", "max_hr": "999"}).status_code == 400
|
||||
assert (
|
||||
client.post("/training/zones", data={"mode": "auto", "birthday": "2099-01-01"}).status_code
|
||||
== 400
|
||||
)
|
||||
with app.app_context():
|
||||
assert db.session.get(User, 1).hr_zones["boundaries"] == [122, 152, 167, 181]
|
||||
assert db.session.get(User, 2).hr_zones is None
|
||||
assert b"Manual zone boundaries" in client.get("/settings").data
|
||||
Reference in New Issue
Block a user