Add extended health metrics and trends UI

This commit is contained in:
Alex
2026-05-26 16:28:07 +03:00
parent a70439194e
commit d52a2d6d1d
13 changed files with 1387 additions and 380 deletions
+163 -11
View File
@@ -3,12 +3,15 @@
import asyncio
import json
import os
import time
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
import miband_tracker.bot.app as bot_app
from miband_tracker import storage
from miband_tracker.config import Settings
from miband_tracker.sync import SyncResult
@@ -23,6 +26,20 @@ class FakeUpdate:
callback_query = None
def _settings(tmp_path: Path) -> Settings:
return Settings(
data_dir=tmp_path,
db_path=tmp_path / "miband.db",
status_path=tmp_path / "status.json",
bot_state_db_path=tmp_path / "fitness_bot_state.db",
telegram_bot_token="token",
telegram_allowed_user_id=123,
sync_interval=900,
query_duration=2,
enable_fds_sleep_details=True,
)
def test_service_menu_does_not_expose_invites_or_second_user() -> None:
keyboard = bot_app.more_keyboard()
labels = [
@@ -84,17 +101,7 @@ async def test_start_xiaomi_login_saves_token_syncs_and_opens_menu(
async def close(self):
return None
settings = Settings(
data_dir=tmp_path,
db_path=tmp_path / "miband.db",
status_path=tmp_path / "status.json",
bot_state_db_path=tmp_path / "fitness_bot_state.db",
telegram_bot_token="token",
telegram_allowed_user_id=123,
sync_interval=900,
query_duration=2,
enable_fds_sleep_details=True,
)
settings = _settings(tmp_path)
run_sync = AsyncMock(return_value=SyncResult(True, user_id=123))
show_main_menu = AsyncMock()
@@ -114,3 +121,148 @@ async def test_start_xiaomi_login_saves_token_syncs_and_opens_menu(
assert data["service_token"] == "service"
run_sync.assert_awaited_once_with(123, settings)
show_main_menu.assert_awaited_once_with(update, context)
def test_main_menu_renders_extended_metrics(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
settings = _settings(tmp_path)
db_path = settings.canonical_user_db_path()
storage.init_health_db(db_path)
with storage.sqlite_conn(db_path, row_factory=False) as conn:
conn.execute(
"INSERT INTO steps_daily (date, total_steps, calories, distance_m, last_sync) VALUES (?, ?, ?, ?, ?)",
("2026-05-26", 451, 22.0, 296.0, 1),
)
conn.execute("INSERT INTO heart_rate (timestamp, value) VALUES (?, ?)", (1779768600, 73))
conn.execute("INSERT INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, ?)", (1779768900, 99.0, "latest"))
conn.execute("INSERT INTO stress (timestamp, value) VALUES (?, ?)", (1779757800, 29))
conn.execute(
"""
INSERT INTO calories_daily
(date, total_cal, active_cal, valid_stand_hours, intensity_minutes, last_sync)
VALUES (?, ?, ?, ?, ?, ?)
""",
("2026-05-26", 55.0, None, 1, 6, 1),
)
conn.commit()
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(db_path))
rendered = bot_app.main_menu_text()
assert "451" in rendered
assert "99%" in rendered
assert "🧘" in rendered
assert "22</b> ккал" in rendered
def test_workouts_text_renders_recent_workout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
settings = _settings(tmp_path)
db_path = settings.canonical_user_db_path()
storage.init_health_db(db_path)
with storage.sqlite_conn(db_path, row_factory=False) as conn:
conn.execute(
"""
INSERT INTO workouts
(workout_id, sport_type, start_time, end_time, duration_sec,
calories, avg_hr, max_hr, min_hr, watermark, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("w1", "free_training", 1779752903, 1779753050, 142, 7.0, 95, 152, 80, 1, "{}"),
)
conn.commit()
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(db_path))
rendered = bot_app.workouts_text()
assert "Тренировки" in rendered
assert "Свободная" in rendered
assert "95 bpm" in rendered
def test_sleep_text_has_no_fds_hint_or_calendar_button(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
settings = _settings(tmp_path)
db_path = settings.canonical_user_db_path()
storage.init_health_db(db_path)
with storage.sqlite_conn(db_path, row_factory=False) as conn:
conn.execute(
"""
INSERT INTO sleep_daily
(date, light_sleep_min, deep_sleep_min, start_time, end_time,
rem_sleep_min, awake_min, total_duration_min, sleep_score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("2026-05-26", 137, 123, 1779744540, 1779770460, 136, 0, 396, 64),
)
conn.commit()
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(db_path))
rendered = bot_app.latest_sleep_text()
keyboard_labels = [
button.text
for row in bot_app.back_keyboard().inline_keyboard
for button in row
]
assert "Детали подтягиваются" not in rendered
assert "FDS" not in rendered
assert "Календарь" not in keyboard_labels
def test_trends_screen_labels_and_keyboard() -> None:
rendered = bot_app.period_text(3650)
keyboard = bot_app.trends_keyboard(3650)
labels = [
button.text
for row in keyboard.inline_keyboard
for button in row
]
assert "Тренды" in rendered
assert "Все время" in rendered
assert not any("Аналитика" in label for label in labels)
assert not any("Детали сна" in label for label in labels)
assert any("Все время" in label for label in labels)
@pytest.mark.asyncio
async def test_auto_refresh_updates_saved_menu_after_status_change(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = _settings(tmp_path)
storage.init_health_db(settings.canonical_user_db_path())
storage.init_state_db(settings)
storage.set_user_menu_msg_id(settings, 123, 99)
settings.canonical_user_status_path(123).write_text("{}", encoding="utf-8")
class FakeBot:
edit_message_text = AsyncMock()
class FakeApp:
bot = FakeBot()
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(settings.canonical_user_db_path()))
monkeypatch.setattr(bot_app, "AUTO_MENU_REFRESH_INTERVAL", 0.01)
task = asyncio.create_task(bot_app.auto_refresh_main_menu_loop(FakeApp()))
await asyncio.sleep(0.02)
now = time.time() + 1
path = settings.canonical_user_status_path(123)
path.write_text('{"last_sync": 1}', encoding="utf-8")
os.utime(path, (now, now))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
FakeApp.bot.edit_message_text.assert_awaited()
+63 -1
View File
@@ -32,7 +32,54 @@ def test_init_health_db_is_idempotent(tmp_path: Path) -> None:
row["name"]
for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
}
assert {"steps_daily", "sleep_daily", "heart_rate", "blood_oxygen"}.issubset(tables)
assert {
"steps_daily",
"sleep_daily",
"heart_rate",
"blood_oxygen",
"stress",
"calories_daily",
"weight",
"workouts",
}.issubset(tables)
def test_extended_health_tables_have_expected_columns(tmp_path: Path) -> None:
db_path = tmp_path / "miband_123.db"
storage.init_health_db(db_path)
with storage.sqlite_conn(db_path, row_factory=False) as conn:
columns = {
table: {
row[1]
for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
}
for table in ("stress", "calories_daily", "weight", "workouts")
}
assert columns["stress"] == {"timestamp", "value"}
assert {
"date",
"total_cal",
"active_cal",
"valid_stand_hours",
"intensity_minutes",
"last_sync",
}.issubset(columns["calories_daily"])
assert {"timestamp", "weight_kg", "bmi", "body_fat_pct"}.issubset(columns["weight"])
assert {
"workout_id",
"sport_type",
"start_time",
"end_time",
"duration_sec",
"calories",
"avg_hr",
"max_hr",
"min_hr",
"watermark",
"raw_json",
}.issubset(columns["workouts"])
def test_zip_export_includes_non_empty_tables_only(tmp_path: Path) -> None:
@@ -44,9 +91,24 @@ def test_zip_export_includes_non_empty_tables_only(tmp_path: Path) -> None:
"INSERT INTO steps_daily (date, total_steps, calories, distance_m, last_sync) VALUES (?, ?, ?, ?, ?)",
("2026-05-24", 1000, 10.0, 800.0, 1),
)
conn.execute(
"INSERT INTO stress (timestamp, value) VALUES (?, ?)",
(1779760000, 42),
)
conn.execute(
"""
INSERT INTO workouts
(workout_id, sport_type, start_time, end_time, duration_sec,
calories, avg_hr, max_hr, min_hr, watermark, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("w1", "free_training", 1779760000, 1779760600, 600, 55.0, 110, 130, 90, 1, "{}"),
)
conn.commit()
archive = storage.zip_export(settings)
assert archive.getbuffer().nbytes > 0
assert b"steps_daily.csv" in archive.getvalue()
assert b"stress.csv" in archive.getvalue()
assert b"workouts.csv" in archive.getvalue()
+48 -1
View File
@@ -6,7 +6,8 @@ from pathlib import Path
import pytest
from miband_tracker.config import Settings
from miband_tracker.sync import run_sync
from miband_tracker.storage import init_health_db, sqlite_conn
from miband_tracker.sync import _sync_workouts, run_sync
@pytest.mark.asyncio
@@ -28,3 +29,49 @@ async def test_run_sync_missing_token_returns_failed_result(tmp_path: Path) -> N
assert not result.success
assert result.user_id == 123
assert "Token file not found" in (result.error or "")
@pytest.mark.asyncio
async def test_sync_workouts_inserts_watermark_records(tmp_path: Path) -> None:
db_path = tmp_path / "miband_123.db"
init_health_db(db_path)
class FakeClient:
async def _request(self, method, path, params):
assert method == "GET"
assert path == "/app/v1/data/get_sport_records_by_watermark"
assert params["relative_uid"] == 456
return {
"result": {
"has_more": False,
"sport_records": [
{
"sid": "w1",
"key": "free_training",
"time": 1779752903,
"watermark": 12345,
"value": (
'{"start_time": 1779752903, "end_time": 1779753050, '
'"duration": 142, "calories": 7, "avg_hrm": 95, '
'"max_hrm": 152, "min_hrm": 80}'
),
}
],
}
}
counters = {"workouts": 0}
with sqlite_conn(db_path, row_factory=False) as conn:
cursor = conn.cursor()
await _sync_workouts(FakeClient(), cursor, counters, 456)
conn.commit()
with sqlite_conn(db_path) as conn:
row = conn.execute("SELECT * FROM workouts WHERE workout_id = ?", ("w1",)).fetchone()
assert counters["workouts"] == 1
assert row is not None
assert row["sport_type"] == "free_training"
assert row["duration_sec"] == 142
assert row["avg_hr"] == 95
assert row["watermark"] == 12345