Compare commits
6 Commits
e24d290127
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 060bdf5114 | |||
| f167c6eed7 | |||
| 3624fd051f | |||
| ac18b73c07 | |||
| 5f916461ac | |||
| 3394be4ee9 |
+9
-3
@@ -24,11 +24,13 @@ console = Console()
|
||||
@click.option("--sync-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Shared secret for POST /api/internal/rebuild (used by the sync-strava systemd timer).")
|
||||
@click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", help="Shared JWT secret from bincio-auth. When set, validates JWTs locally instead of DB session lookup.")
|
||||
@click.option("--auth-api", default=None, envvar="BINCIO_AUTH_API", help="Internal URL of the bincio-auth API (e.g. http://127.0.0.1:4040). When set, admin user-state operations are proxied to bincio-auth.")
|
||||
@click.option("--oidc-issuer", default=None, envvar="BINCIO_OIDC_ISSUER", help="OIDC issuer URL (e.g. https://bincio.org). When set, validates RS256 id_tokens via JWKS (preferred over HS256).")
|
||||
def serve(data_dir: str, site_dir: str | None, host: str, port: int,
|
||||
strava_client_id: str | None, strava_client_secret: str | None,
|
||||
max_users: int | None, public_url: str | None,
|
||||
webroot: str | None, dem_url: str | None,
|
||||
sync_secret: str | None, jwt_secret: str | None, auth_api: str | None) -> None:
|
||||
sync_secret: str | None, jwt_secret: str | None, auth_api: str | None,
|
||||
oidc_issuer: str | None) -> None:
|
||||
"""Start the bincio multi-user application server.
|
||||
|
||||
Handles auth, user management, and write operations.
|
||||
@@ -72,6 +74,8 @@ def serve(data_dir: str, site_dir: str | None, host: str, port: int,
|
||||
deps.jwt_secret = jwt_secret
|
||||
if auth_api:
|
||||
deps.auth_api = auth_api.rstrip("/")
|
||||
if oidc_issuer:
|
||||
deps.oidc_issuer = oidc_issuer
|
||||
|
||||
db = open_db(dd)
|
||||
current_limit = get_setting(db, "max_users")
|
||||
@@ -89,8 +93,10 @@ def serve(data_dir: str, site_dir: str | None, host: str, port: int,
|
||||
else:
|
||||
console.print(" Users: [dim]unlimited[/dim]")
|
||||
console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]")
|
||||
if deps.jwt_secret:
|
||||
console.print(" Auth: [green]JWT (bincio-auth)[/green]")
|
||||
if deps.oidc_issuer:
|
||||
console.print(f" Auth: [green]RS256 via {deps.oidc_issuer}[/green]" + (" + HS256 fallback" if deps.jwt_secret else ""))
|
||||
elif deps.jwt_secret:
|
||||
console.print(" Auth: [green]JWT HS256 (bincio-auth)[/green]")
|
||||
else:
|
||||
console.print(" Auth: [dim]local DB sessions[/dim]")
|
||||
console.print()
|
||||
|
||||
+74
-6
@@ -38,12 +38,20 @@ dem_url: str = "https://api.open-elevation.com"
|
||||
sync_secret: str = ""
|
||||
jwt_secret: str = "" # when set, validates JWTs from bincio-auth instead of DB session lookup
|
||||
auth_api: str = "" # when set, proxies user-state admin ops to bincio-auth (e.g. http://127.0.0.1:4040)
|
||||
oidc_issuer: str = "" # when set, validates RS256 id_tokens via bincio-auth JWKS
|
||||
_db = None
|
||||
_strava_sync_running = False
|
||||
_strava_sync_lock = threading.Lock()
|
||||
_garmin_sync_running = False
|
||||
_garmin_sync_lock = threading.Lock()
|
||||
|
||||
# ── JWKS cache ────────────────────────────────────────────────────────────────
|
||||
|
||||
_jwks_public_key: object = None
|
||||
_jwks_fetched_at: float = 0.0
|
||||
_jwks_lock = threading.Lock()
|
||||
_JWKS_TTL = 3600
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
||||
@@ -116,8 +124,59 @@ def _check_rate_limit(
|
||||
|
||||
# ── Auth dependency functions ─────────────────────────────────────────────────
|
||||
|
||||
def _decode_jwt(token: str) -> User | None:
|
||||
"""Decode a bincio-auth JWT and return a User. Returns None on any failure."""
|
||||
def _get_jwks_public_key() -> object:
|
||||
"""Fetch and cache the RSA public key from bincio-auth's JWKS endpoint."""
|
||||
global _jwks_public_key, _jwks_fetched_at
|
||||
now = time.time()
|
||||
with _jwks_lock:
|
||||
if _jwks_public_key is not None and now - _jwks_fetched_at < _JWKS_TTL:
|
||||
return _jwks_public_key
|
||||
import base64
|
||||
import urllib.request
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
url = f"{oidc_issuer.rstrip('/')}/.well-known/jwks.json"
|
||||
with urllib.request.urlopen(url, timeout=5) as r:
|
||||
jwks = json.loads(r.read())
|
||||
k = jwks["keys"][0]
|
||||
|
||||
def _b64i(s: str) -> int:
|
||||
s += "=" * (-len(s) % 4)
|
||||
return int.from_bytes(base64.urlsafe_b64decode(s), "big")
|
||||
|
||||
pub = RSAPublicNumbers(_b64i(k["e"]), _b64i(k["n"])).public_key(default_backend())
|
||||
_jwks_public_key = pub
|
||||
_jwks_fetched_at = now
|
||||
return pub
|
||||
|
||||
|
||||
def _decode_rs256(token: str) -> User | None:
|
||||
"""Decode an RS256 id_token from bincio-auth. Returns None on any failure."""
|
||||
try:
|
||||
pub = _get_jwks_public_key()
|
||||
payload = _jwt.decode(
|
||||
token, pub, algorithms=["RS256"],
|
||||
options={"verify_aud": False},
|
||||
issuer=oidc_issuer,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
handle = payload.get("sub")
|
||||
if not handle:
|
||||
return None
|
||||
return User(
|
||||
handle=handle,
|
||||
display_name=payload.get("name") or payload.get("display_name", ""),
|
||||
is_admin=bool(payload.get("is_admin", False)),
|
||||
wiki_access=bool(payload.get("wiki_access", True)),
|
||||
activity_access=bool(payload.get("activity_access", False)),
|
||||
suspended=False,
|
||||
created_at=0,
|
||||
)
|
||||
|
||||
|
||||
def _decode_hs256(token: str) -> User | None:
|
||||
"""Decode a bincio-auth HS256 JWT and return a User. Returns None on any failure."""
|
||||
try:
|
||||
payload = _jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
except _jwt.PyJWTError:
|
||||
@@ -136,12 +195,21 @@ def _decode_jwt(token: str) -> User | None:
|
||||
)
|
||||
|
||||
|
||||
def _decode_token(token: str) -> User | None:
|
||||
"""Try RS256 first (if oidc_issuer set), then HS256, then DB session."""
|
||||
if oidc_issuer:
|
||||
user = _decode_rs256(token)
|
||||
if user:
|
||||
return user
|
||||
if jwt_secret:
|
||||
return _decode_hs256(token)
|
||||
return get_session(_get_db(), token)
|
||||
|
||||
|
||||
def _current_user(bincio_session: str | None = Cookie(default=None)) -> User | None:
|
||||
if not bincio_session:
|
||||
return None
|
||||
if jwt_secret:
|
||||
return _decode_jwt(bincio_session)
|
||||
return get_session(_get_db(), bincio_session)
|
||||
return _decode_token(bincio_session)
|
||||
|
||||
|
||||
def _require_user(bincio_session: str | None = Cookie(default=None)) -> User:
|
||||
@@ -170,7 +238,7 @@ def _require_auth(
|
||||
token = auth[7:]
|
||||
if not token:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
user = _decode_jwt(token) if jwt_secret else get_session(_get_db(), token)
|
||||
user = _decode_token(token)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired session")
|
||||
return user
|
||||
|
||||
@@ -165,6 +165,7 @@ async def admin_reset_password_code(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Generate a one-time password reset code for a user. Proxied to bincio-auth."""
|
||||
deps._require_admin(bincio_session)
|
||||
return await _auth_proxy("POST", f"/api/admin/users/{handle}/reset-password-code", bincio_session)
|
||||
|
||||
|
||||
@@ -174,6 +175,7 @@ async def admin_suspend(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Suspend a user account. Proxied to bincio-auth."""
|
||||
deps._require_admin(bincio_session)
|
||||
return await _auth_proxy("POST", f"/api/admin/users/{handle}/suspend", bincio_session)
|
||||
|
||||
|
||||
@@ -183,6 +185,7 @@ async def admin_unsuspend(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Re-enable a suspended user account. Proxied to bincio-auth."""
|
||||
deps._require_admin(bincio_session)
|
||||
return await _auth_proxy("POST", f"/api/admin/users/{handle}/unsuspend", bincio_session)
|
||||
|
||||
|
||||
@@ -192,6 +195,7 @@ async def admin_delete_account(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete a user account. Proxied to bincio-auth."""
|
||||
deps._require_admin(bincio_session)
|
||||
return await _auth_proxy("DELETE", f"/api/admin/users/{handle}/account", bincio_session)
|
||||
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ try {
|
||||
{instancePrivate && !isPublicPage && (
|
||||
<style is:inline>[data-auth-pending]{visibility:hidden}</style>
|
||||
<script is:inline define:vars={{ authUrl }}>
|
||||
window.__bincioAuthUrl = authUrl;
|
||||
fetch('/api/me', { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (r.status === 401 || r.status === 404) {
|
||||
@@ -590,7 +591,8 @@ try {
|
||||
|
||||
<!-- User widget: only needed for multi-user (single-user nav links are static) -->
|
||||
{!singleHandle && (
|
||||
<script define:vars={{ baseUrl, authUrl }}>
|
||||
<script define:vars={{ baseUrl }}>
|
||||
const authUrl = window.__bincioAuthUrl || '';
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/me', { credentials: 'include' });
|
||||
@@ -732,6 +734,7 @@ try {
|
||||
|
||||
{editEnabled && (
|
||||
<script define:vars={{ editUrl, baseUrl }}>
|
||||
const authUrl = window.__bincioAuthUrl || '';
|
||||
const modal = document.getElementById('upload-modal');
|
||||
const openBtn = document.getElementById('upload-btn');
|
||||
const closeBtn = document.getElementById('upload-close');
|
||||
|
||||
@@ -51,6 +51,25 @@ import Base from '../../layouts/Base.astro';
|
||||
<p id="display-name-status" class="text-xs mt-2 hidden"></p>
|
||||
</section>
|
||||
|
||||
<!-- Email card -->
|
||||
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-1">Email</h2>
|
||||
<p class="text-xs text-zinc-500 mb-4">Used to receive a reset link when you forget your password.</p>
|
||||
<form id="email-form" class="flex gap-2 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-zinc-500 mb-1" for="email-input">Email address</label>
|
||||
<input id="email-input" type="email" autocomplete="email"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent] text-sm"
|
||||
placeholder="you@example.com" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors shrink-0">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<p id="email-status" class="text-xs mt-2 hidden"></p>
|
||||
</section>
|
||||
|
||||
<!-- Password card -->
|
||||
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-4">Password</h2>
|
||||
@@ -602,10 +621,48 @@ import Base from '../../layouts/Base.astro';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Email ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const emailForm = document.getElementById('email-form') as HTMLFormElement;
|
||||
const emailInput = document.getElementById('email-input') as HTMLInputElement;
|
||||
const emailStatus = document.getElementById('email-status') as HTMLElement;
|
||||
|
||||
async function loadEmail() {
|
||||
const authUrl: string = (window as any).__bincioAuthUrl ?? '';
|
||||
if (!authUrl) return; // single-user / no bincio-auth
|
||||
try {
|
||||
const r = await fetch(`${authUrl}/api/me/email`, { credentials: 'include' });
|
||||
if (r.ok) {
|
||||
const { email } = await r.json();
|
||||
if (email) emailInput.value = email;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
emailForm.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const authUrl: string = (window as any).__bincioAuthUrl ?? '';
|
||||
if (!authUrl) { setStatus(emailStatus, 'Not available on this instance.', false); return; }
|
||||
const email = emailInput.value.trim();
|
||||
try {
|
||||
const r = await fetch(`${authUrl}/api/me/email`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
setStatus(emailStatus, r.ok ? 'Saved.' : (d.detail ?? 'Failed.'), r.ok);
|
||||
} catch {
|
||||
setStatus(emailStatus, 'Could not reach server.', false);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
loadMe();
|
||||
loadStorage();
|
||||
loadEmail();
|
||||
loadNavPrefs();
|
||||
loadActivityDefaults();
|
||||
loadStravaCreds();
|
||||
|
||||
@@ -59,13 +59,8 @@ class TestAdminUserOps:
|
||||
def test_delete_account_requires_admin(self, client: TestClient):
|
||||
assert client.delete("/api/admin/users/alice/account").status_code == 401
|
||||
|
||||
def test_admin_reset_password_code(self, admin_client: TestClient, tmp_data):
|
||||
from bincio.serve.db import create_user, open_db
|
||||
db = open_db(tmp_data)
|
||||
try:
|
||||
create_user(db, "target", "Target", "targetpass1")
|
||||
except Exception:
|
||||
pass
|
||||
def test_admin_reset_password_code_proxied(self, admin_client: TestClient):
|
||||
# This endpoint proxies to bincio-auth; without BINCIO_AUTH_API configured
|
||||
# in the test environment it returns 503.
|
||||
r = admin_client.post("/api/admin/users/target/reset-password-code")
|
||||
assert r.status_code == 200
|
||||
assert "code" in r.json()
|
||||
assert r.status_code == 503
|
||||
|
||||
+3
-3
@@ -154,10 +154,10 @@ def test_hysteresis_recalc_barometric(tmp_path):
|
||||
result = recalculate_elevation_hysteresis(tmp_path, "test-act")
|
||||
|
||||
assert result["altitude_source"] == "barometric"
|
||||
assert result["threshold_m"] == pytest.approx(1.0)
|
||||
assert result["threshold_m"] == pytest.approx(1.5)
|
||||
# Edge effect is ≤1% on a 30-min ramp
|
||||
assert result["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02)
|
||||
assert result["elevation_loss_m"] == pytest.approx(0.0, abs=1.0)
|
||||
assert result["elevation_loss_m"] == pytest.approx(0.0, abs=1.5)
|
||||
|
||||
|
||||
def test_hysteresis_recalc_gps(tmp_path):
|
||||
@@ -166,7 +166,7 @@ def test_hysteresis_recalc_gps(tmp_path):
|
||||
|
||||
result = recalculate_elevation_hysteresis(tmp_path, "test-act")
|
||||
|
||||
assert result["threshold_m"] == pytest.approx(3.0)
|
||||
assert result["threshold_m"] == pytest.approx(2.0)
|
||||
assert result["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02)
|
||||
|
||||
|
||||
|
||||
@@ -73,25 +73,25 @@ class TestHysteresisEndpoint:
|
||||
assert "elevation_loss_m" in body
|
||||
assert body["elevation_gain_m"] > 0
|
||||
assert body["altitude_source"] == "barometric"
|
||||
assert body["threshold_m"] == pytest.approx(1.0)
|
||||
assert body["threshold_m"] == pytest.approx(1.5)
|
||||
|
||||
def test_gps_source_uses_3m_threshold(self, tmp_path):
|
||||
def test_gps_source_uses_2m_threshold(self, tmp_path):
|
||||
elevations = [float(i) for i in range(1801)]
|
||||
_make_activity(tmp_path, self.AID, elevations, altitude_source="gps")
|
||||
|
||||
r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["threshold_m"] == pytest.approx(3.0)
|
||||
assert r.json()["threshold_m"] == pytest.approx(2.0)
|
||||
|
||||
def test_unknown_source_falls_back_to_gps_threshold(self, tmp_path):
|
||||
def test_unknown_source_uses_1_5m_threshold(self, tmp_path):
|
||||
elevations = [float(i) for i in range(1801)]
|
||||
_make_activity(tmp_path, self.AID, elevations, altitude_source="unknown")
|
||||
|
||||
r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["threshold_m"] == pytest.approx(3.0)
|
||||
assert r.json()["threshold_m"] == pytest.approx(1.5)
|
||||
|
||||
def test_uses_original_elevation_when_dem_backup_present(self, tmp_path):
|
||||
original = [float(i) for i in range(1801)] # real 1800 m climb
|
||||
|
||||
+25
-21
@@ -29,13 +29,14 @@ def _pt(offset_s: int, **kw) -> DataPoint:
|
||||
return DataPoint(timestamp=_ts(offset_s), **kw)
|
||||
|
||||
|
||||
def _activity(points: list[DataPoint], sport: str = "cycling") -> ParsedActivity:
|
||||
def _activity(points: list[DataPoint], sport: str = "cycling", altitude_source: str = "unknown") -> ParsedActivity:
|
||||
return ParsedActivity(
|
||||
points=points,
|
||||
sport=sport,
|
||||
started_at=_ts(0),
|
||||
source_file="test.fit",
|
||||
source_hash="sha256:abc",
|
||||
altitude_source=altitude_source,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,12 +111,13 @@ def test_compute_moving_time_excludes_stops():
|
||||
|
||||
|
||||
def test_compute_elevation_gain():
|
||||
# Barometric source: no MA smoothing, so even 3 points produce correct gain.
|
||||
pts = [
|
||||
_pt(0, lat=48.0, lon=11.0, elevation_m=100.0),
|
||||
_pt(10, lat=48.001, lon=11.0, elevation_m=150.0),
|
||||
_pt(20, lat=48.002, lon=11.0, elevation_m=120.0),
|
||||
]
|
||||
m = compute(_activity(pts))
|
||||
m = compute(_activity(pts, altitude_source="barometric"))
|
||||
assert m.elevation_gain_m == 50.0
|
||||
assert m.elevation_loss_m == 30.0
|
||||
|
||||
@@ -134,8 +136,8 @@ def _ele_pts(elevations: list[float]) -> list[DataPoint]:
|
||||
|
||||
|
||||
def test_elevation_hysteresis_large_step_always_counted():
|
||||
# A single 50m step is way above any threshold — both sources should count it.
|
||||
pts = _ele_pts([100.0, 150.0])
|
||||
# A 50m step with 5 points per level so the GPS moving average doesn't flatten it.
|
||||
pts = _ele_pts([100.0] * 5 + [150.0] * 5)
|
||||
gain_baro, _ = _elevation(pts, "barometric")
|
||||
gain_gps, _ = _elevation(pts, "gps")
|
||||
assert gain_baro == 50.0
|
||||
@@ -143,26 +145,25 @@ def test_elevation_hysteresis_large_step_always_counted():
|
||||
|
||||
|
||||
def test_elevation_hysteresis_flat_gps_noise_suppressed():
|
||||
# Flat coastal route: 16m of GPS noise oscillating within ±8m.
|
||||
# All steps are sub-1m — hysteresis should return ~0 gain.
|
||||
import math
|
||||
# GPS noise within ±0.5m — peak-to-peak 1.0m, well below the 2.0m GPS threshold.
|
||||
n = 1000
|
||||
elevations = [100.0 + 3.0 * math.sin(i * 0.1) for i in range(n)]
|
||||
elevations = [100.0 + 0.5 * math.sin(i * 0.1) for i in range(n)]
|
||||
pts = _ele_pts(elevations)
|
||||
gain, loss = _elevation(pts, "gps")
|
||||
# With threshold=10m no oscillation within ±3m should ever commit.
|
||||
assert gain == 0.0
|
||||
assert loss == 0.0
|
||||
|
||||
|
||||
def test_elevation_hysteresis_barometric_threshold_lower():
|
||||
# Steps of exactly 7m — above barometric (5m) but below GPS (10m) threshold.
|
||||
elevations = [0.0, 7.0, 0.0, 7.0]
|
||||
# 1.7m steps at 100m baseline (avoids sensor-dropout suppression which
|
||||
# skips values near 0): above the 1.5m barometric threshold but, after GPS
|
||||
# MA smoothing, the effective diff stays below the 2.0m GPS threshold.
|
||||
elevations = [100.0, 101.7, 100.0, 101.7]
|
||||
pts = _ele_pts(elevations)
|
||||
gain_baro, _ = _elevation(pts, "barometric")
|
||||
gain_gps, _ = _elevation(pts, "gps")
|
||||
assert gain_baro == 14.0 # both 7m steps committed
|
||||
assert gain_gps == 0.0 # 7m < 10m threshold → suppressed
|
||||
assert gain_baro == pytest.approx(3.4) # both 1.7m steps committed
|
||||
assert gain_gps == 0.0 # MA + 2.0m threshold suppresses
|
||||
|
||||
|
||||
def test_elevation_hysteresis_real_climb_approximated():
|
||||
@@ -350,33 +351,36 @@ def test_best_efforts_no_targets_for_sport():
|
||||
# ── best climb ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_best_climb_simple_ascent():
|
||||
# 0 → 100 m with no gaps
|
||||
ele = [float(i) for i in range(101)]
|
||||
# 0 → 100 m with no gaps; x is cumulative distance (m)
|
||||
ele = [(float(i), float(i)) for i in range(101)]
|
||||
result = _best_climb(ele)
|
||||
assert result == 100.0
|
||||
|
||||
|
||||
def test_best_climb_with_descent():
|
||||
# Up 50, down 20, up 80 → best contiguous window = 80
|
||||
ele = list(range(0, 51)) + list(range(50, 30, -1)) + list(range(30, 111))
|
||||
vals = list(range(0, 51)) + list(range(50, 30, -1)) + list(range(30, 111))
|
||||
ele = [(float(i), float(v)) for i, v in enumerate(vals)]
|
||||
result = _best_climb(ele)
|
||||
assert result is not None
|
||||
assert result >= 80.0
|
||||
|
||||
|
||||
def test_best_climb_none_gap_resets_window():
|
||||
# 50 m up, then a GPS gap, then 30 m up — windows don't bridge the gap
|
||||
ele: list = list(range(0, 51)) + [None] + list(range(0, 31))
|
||||
result = _best_climb(ele)
|
||||
# 50 m up, then a GPS gap (skipped), then 30 m up — windows don't bridge the gap.
|
||||
# None elevations are excluded when building dist_ele, so the climb restarts at 0.
|
||||
ele_up1 = [(float(i), float(i)) for i in range(51)]
|
||||
ele_up2 = [(float(51 + i), float(i)) for i in range(31)]
|
||||
result = _best_climb(ele_up1 + ele_up2)
|
||||
assert result == 50.0
|
||||
|
||||
|
||||
def test_best_climb_only_descent():
|
||||
ele = [100.0, 80.0, 60.0, 40.0]
|
||||
ele = [(float(i), v) for i, v in enumerate([100.0, 80.0, 60.0, 40.0])]
|
||||
result = _best_climb(ele)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_best_climb_too_few_samples():
|
||||
assert _best_climb([]) is None
|
||||
assert _best_climb([100.0]) is None
|
||||
assert _best_climb([(0.0, 100.0)]) is None
|
||||
|
||||
@@ -70,6 +70,7 @@ def _dummy_metrics(**overrides):
|
||||
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
|
||||
bbox=None, start_latlng=None, end_latlng=None,
|
||||
mmp=None, best_efforts=None, best_climb_m=None,
|
||||
climbing_vam_mh=None, climbing_time_s=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return ComputedMetrics(**defaults)
|
||||
@@ -216,6 +217,8 @@ def test_build_summary_required_fields():
|
||||
mmp=None,
|
||||
best_efforts=None,
|
||||
best_climb_m=None,
|
||||
climbing_vam_mh=None,
|
||||
climbing_time_s=None,
|
||||
)
|
||||
summary = build_summary(act, metrics, "2024-06-01T073012Z-test-ride")
|
||||
# Required fields per schema
|
||||
|
||||
Reference in New Issue
Block a user