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()