mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-07-19 04:00:15 +03:00
Add extended health metrics and trends UI
This commit is contained in:
+163
-11
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user