smart tdee
This commit is contained in:
@@ -1,84 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from app.fitness.activity_budget import (
|
||||
compute_activity_bonus,
|
||||
scale_targets,
|
||||
steps_bonus_kcal,
|
||||
)
|
||||
|
||||
|
||||
PROFILE = {
|
||||
"weight_kg": 70,
|
||||
"activity_level": "moderate",
|
||||
"weekly_workouts": 3,
|
||||
"calorie_target": 2000,
|
||||
"protein_g": 126,
|
||||
"fat_g": 56,
|
||||
"carbs_g": 250,
|
||||
"water_l": 2.5,
|
||||
}
|
||||
|
||||
|
||||
class ActivityBudgetTests(unittest.TestCase):
|
||||
def test_no_bonus_at_baseline(self) -> None:
|
||||
bonus = compute_activity_bonus(
|
||||
PROFILE,
|
||||
steps_total=9000,
|
||||
workouts=[{"active_calories": 85}],
|
||||
)
|
||||
self.assertEqual(bonus.steps_bonus_kcal, 0.0)
|
||||
self.assertEqual(bonus.workout_bonus_kcal, 0.0)
|
||||
self.assertEqual(bonus.total_bonus_kcal, 0.0)
|
||||
self.assertEqual(bonus.scale_factor, 1.0)
|
||||
|
||||
def test_steps_and_workout_bonus(self) -> None:
|
||||
bonus = compute_activity_bonus(
|
||||
PROFILE,
|
||||
steps_total=21800,
|
||||
workouts=[{"active_calories": 155}],
|
||||
)
|
||||
self.assertGreater(bonus.steps_bonus_kcal, 0)
|
||||
self.assertGreater(bonus.workout_bonus_kcal, 0)
|
||||
self.assertEqual(
|
||||
bonus.total_bonus_kcal,
|
||||
round(bonus.steps_bonus_kcal + bonus.workout_bonus_kcal, 1),
|
||||
)
|
||||
self.assertGreater(bonus.scale_factor, 1.0)
|
||||
|
||||
def test_steps_bonus_formula(self) -> None:
|
||||
kcal = steps_bonus_kcal(steps=21800, baseline_steps=9000, weight_kg=70)
|
||||
self.assertEqual(kcal, round(12800 * 70 * 0.0005, 1))
|
||||
|
||||
def test_proportional_macro_scale(self) -> None:
|
||||
base = {
|
||||
"calories": 2000,
|
||||
"protein_g": 100,
|
||||
"fat_g": 50,
|
||||
"carbs_g": 200,
|
||||
"water_ml": 2500,
|
||||
}
|
||||
effective, targets_base = scale_targets(base, 500)
|
||||
self.assertEqual(effective["calories"], 2500)
|
||||
self.assertEqual(targets_base, base)
|
||||
self.assertEqual(effective["water_ml"], 2500)
|
||||
ratio = effective["calories"] / base["calories"]
|
||||
self.assertAlmostEqual(effective["protein_g"] / base["protein_g"], ratio, places=1)
|
||||
self.assertAlmostEqual(effective["fat_g"] / base["fat_g"], ratio, places=1)
|
||||
self.assertAlmostEqual(effective["carbs_g"] / base["carbs_g"], ratio, places=1)
|
||||
|
||||
def test_floor_at_base_when_no_activity(self) -> None:
|
||||
effective, _ = scale_targets(
|
||||
{
|
||||
"calories": 2045,
|
||||
"protein_g": 156,
|
||||
"fat_g": 57,
|
||||
"carbs_g": 227,
|
||||
"water_ml": 2900,
|
||||
},
|
||||
0,
|
||||
)
|
||||
self.assertEqual(effective["calories"], 2045)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,13 +1,17 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.homelab.openmeteo import (
|
||||
PRECIP_PROB_HINT,
|
||||
RECOMMENDED_SYNC_DOMAINS,
|
||||
RECOMMENDED_SYNC_VARIABLES,
|
||||
SYNC_HINT,
|
||||
_coverage_sufficient,
|
||||
_field_coverage,
|
||||
_hourly_start_index,
|
||||
_local_needs_sync_hint,
|
||||
build_weather_dashboard,
|
||||
format_weather_snapshot,
|
||||
weather_query_relevant,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,49 +25,89 @@ def test_coverage_sufficient():
|
||||
assert _coverage_sufficient(
|
||||
{
|
||||
"current": ["temperature_2m", "weather_code", "wind_speed_10m"],
|
||||
"hourly": ["temperature_2m", "precipitation_probability", "weather_code"],
|
||||
"hourly": ["temperature_2m", "weather_code"],
|
||||
}
|
||||
) is True
|
||||
|
||||
|
||||
def test_field_coverage_partial():
|
||||
raw = {
|
||||
"current": {"time": "2026-06-14T18:15", "temperature_2m": 20.6},
|
||||
"current": {"time": "2026-06-14T18:15", "temperature_2m": 20.6, "weather_code": 2},
|
||||
"hourly": {
|
||||
"time": ["2026-06-14T18:00", "2026-06-14T19:00"],
|
||||
"temperature_2m": [20.0, 19.5],
|
||||
"precipitation": [0.0, 0.0],
|
||||
"weather_code": [2, 3],
|
||||
},
|
||||
"daily": {
|
||||
"time": ["2026-06-14", "2026-06-15"],
|
||||
"temperature_2m_max": [21.0, 18.0],
|
||||
"temperature_2m_min": [12.0, 10.0],
|
||||
"weather_code": [2, 3],
|
||||
},
|
||||
}
|
||||
coverage = _field_coverage(raw)
|
||||
assert coverage["current"] == ["temperature_2m"]
|
||||
assert "temperature_2m" in coverage["hourly"]
|
||||
assert "precipitation" in coverage["hourly"]
|
||||
assert "weather_code" not in coverage["hourly"]
|
||||
assert "temperature_2m" in coverage["current"]
|
||||
assert "weather_code" in coverage["hourly"]
|
||||
assert "temperature_2m_max" in coverage["daily"]
|
||||
|
||||
|
||||
def test_build_weather_dashboard_includes_sync_hint():
|
||||
def test_local_needs_sync_hint():
|
||||
assert _local_needs_sync_hint({"current": ["temperature_2m"], "hourly": ["temperature_2m"]}) is True
|
||||
assert _local_needs_sync_hint(
|
||||
{"current": ["temperature_2m", "weather_code"], "hourly": ["temperature_2m", "weather_code"]}
|
||||
) is False
|
||||
|
||||
|
||||
def test_weather_query_relevant():
|
||||
assert weather_query_relevant("какая погода завтра")
|
||||
assert not weather_query_relevant("напиши код на python")
|
||||
|
||||
|
||||
def test_format_weather_snapshot_includes_tomorrow():
|
||||
snap = {
|
||||
"ok": True,
|
||||
"location": "СПб",
|
||||
"current": {"temperature_c": 20, "conditions": "ясно"},
|
||||
"hourly": [],
|
||||
"daily": [
|
||||
{"label": "Сегодня", "temperature_min_c": 10, "temperature_max_c": 20, "conditions": "ясно"},
|
||||
{"label": "Завтра", "temperature_min_c": 12, "temperature_max_c": 18, "conditions": "дождь"},
|
||||
],
|
||||
}
|
||||
text = format_weather_snapshot(snap)
|
||||
assert "Завтра:" in text
|
||||
assert "None" not in text
|
||||
|
||||
|
||||
def test_build_weather_dashboard_includes_daily():
|
||||
fake_weather = {
|
||||
"ok": True,
|
||||
"location": "Test",
|
||||
"data_source": "local",
|
||||
"local_field_coverage": {"current": ["temperature_2m"], "hourly": ["temperature_2m"]},
|
||||
"field_coverage": {"current": ["temperature_2m"], "hourly": ["temperature_2m"]},
|
||||
"sync_hint": SYNC_HINT,
|
||||
"current": {"temperature_c": 10, "conditions": "неизвестно"},
|
||||
"local_field_coverage": {"current": ["temperature_2m", "weather_code"], "hourly": ["temperature_2m"], "daily": []},
|
||||
"field_coverage": {"current": ["temperature_2m"], "hourly": ["temperature_2m"], "daily": []},
|
||||
"sync_hint": PRECIP_PROB_HINT,
|
||||
"merged_fields": [],
|
||||
"current": {"temperature_c": 10, "conditions": "ясно"},
|
||||
"hourly": [],
|
||||
"daily": [{"label": "Завтра", "temperature_min_c": 5, "temperature_max_c": 12, "conditions": "дождь"}],
|
||||
}
|
||||
with patch("app.homelab.openmeteo.OpenMeteoClient") as mock_cls:
|
||||
client = mock_cls.return_value
|
||||
client.fetch_current_and_hourly.return_value = fake_weather
|
||||
client.fetch_forecast.return_value = fake_weather
|
||||
client.rain_summary.return_value = "ok"
|
||||
client.daily_summary.return_value = "Завтра: 5–12°C"
|
||||
client.cache_status.return_value = {"source": "local", "has_data": True, "cached": True, "ttl_sec": 300}
|
||||
client.location_name = "Test"
|
||||
client.lat = 1.0
|
||||
client.lon = 2.0
|
||||
client.base_url = "http://local"
|
||||
client.cache_ttl = 300
|
||||
result = build_weather_dashboard()
|
||||
assert result["sync_hint"]
|
||||
client.forecast_days = 7
|
||||
result = build_weather_dashboard(days_ahead=7)
|
||||
|
||||
assert result["daily_summary"] == "Завтра: 5–12°C"
|
||||
assert result["recommended_sync"]["domains"] == RECOMMENDED_SYNC_DOMAINS
|
||||
assert result["recommended_sync"]["variables"] == RECOMMENDED_SYNC_VARIABLES
|
||||
assert SYNC_HINT # constant exists
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import FitnessProfile, User
|
||||
from app.fitness.calculators import compute_targets
|
||||
|
||||
|
||||
class TdeeBackfillTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE IF NOT EXISTS _schema_migrations ("
|
||||
"name TEXT PRIMARY KEY, "
|
||||
"applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
|
||||
)
|
||||
)
|
||||
|
||||
def _insert_legacy_profile(self) -> None:
|
||||
Session = sessionmaker(bind=self.engine)
|
||||
with Session() as db:
|
||||
user = User(username="tester", api_token_hash="x")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(
|
||||
FitnessProfile(
|
||||
user_id=user.id,
|
||||
sex="male",
|
||||
age=30,
|
||||
height_cm=180,
|
||||
weight_kg=86,
|
||||
goal="maintain",
|
||||
activity_level="active",
|
||||
calorie_target=3200,
|
||||
protein_g=155,
|
||||
fat_g=89,
|
||||
carbs_g=400,
|
||||
water_l=3.0,
|
||||
neat_base_kcal=None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
def test_backfill_recalculates_stored_targets(self) -> None:
|
||||
from app.db import migrate_fitness
|
||||
|
||||
self._insert_legacy_profile()
|
||||
|
||||
with patch.object(migrate_fitness, "engine", self.engine):
|
||||
updated = migrate_fitness.backfill_tdee_targets(force=True)
|
||||
self.assertEqual(updated, 1)
|
||||
|
||||
Session = sessionmaker(bind=self.engine)
|
||||
with Session() as db:
|
||||
row = db.query(FitnessProfile).one()
|
||||
expected = compute_targets(
|
||||
{
|
||||
"sex": row.sex,
|
||||
"age": row.age,
|
||||
"height_cm": row.height_cm,
|
||||
"weight_kg": row.weight_kg,
|
||||
"goal": row.goal,
|
||||
"neat_base_kcal": 200,
|
||||
}
|
||||
)
|
||||
self.assertEqual(row.neat_base_kcal, 200.0)
|
||||
self.assertEqual(row.calorie_target, expected["calorie_target"])
|
||||
self.assertEqual(row.protein_g, expected["protein_g"])
|
||||
self.assertLess(row.calorie_target, 3200)
|
||||
|
||||
def test_backfill_runs_once(self) -> None:
|
||||
from app.db import migrate_fitness
|
||||
|
||||
self._insert_legacy_profile()
|
||||
|
||||
with patch.object(migrate_fitness, "engine", self.engine):
|
||||
first = migrate_fitness.backfill_tdee_targets(force=True)
|
||||
second = migrate_fitness.backfill_tdee_targets()
|
||||
self.assertEqual(first, 1)
|
||||
self.assertEqual(second, 0)
|
||||
|
||||
def test_macros_backfill_updates_bju_only(self) -> None:
|
||||
from app.db import migrate_fitness
|
||||
from app.fitness.calculators import macro_targets
|
||||
|
||||
self._insert_legacy_profile()
|
||||
|
||||
with patch.object(migrate_fitness, "engine", self.engine):
|
||||
migrate_fitness.backfill_tdee_targets(force=True)
|
||||
Session = sessionmaker(bind=self.engine)
|
||||
with Session() as db:
|
||||
row = db.query(FitnessProfile).one()
|
||||
calorie_before = row.calorie_target
|
||||
row.protein_g = 999
|
||||
db.commit()
|
||||
|
||||
updated = migrate_fitness.backfill_macros_gkg(force=True)
|
||||
self.assertEqual(updated, 1)
|
||||
|
||||
with Session() as db:
|
||||
row = db.query(FitnessProfile).one()
|
||||
expected = macro_targets(calorie_before, row.weight_kg, row.goal)
|
||||
self.assertEqual(row.calorie_target, calorie_before)
|
||||
self.assertEqual(row.protein_g, expected["protein_g"])
|
||||
self.assertEqual(row.fat_g, expected["fat_g"])
|
||||
self.assertEqual(row.carbs_g, expected["carbs_g"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,126 @@
|
||||
import unittest
|
||||
|
||||
from app.fitness.activity_budget import estimate_workout_active_kcal, infer_met, workouts_kcal_total
|
||||
from app.fitness.calculators import (
|
||||
DEFAULT_NEAT_KCAL,
|
||||
bmr_mifflin,
|
||||
compute_daily_targets,
|
||||
compute_targets,
|
||||
compute_tdee,
|
||||
macro_targets,
|
||||
steps_kcal,
|
||||
water_target_l,
|
||||
)
|
||||
|
||||
PROFILE = {
|
||||
"sex": "male",
|
||||
"age": 30,
|
||||
"height_cm": 180,
|
||||
"weight_kg": 86,
|
||||
"goal": "maintain",
|
||||
"neat_base_kcal": 200,
|
||||
}
|
||||
|
||||
|
||||
class TdeeComponentsTests(unittest.TestCase):
|
||||
def test_rest_day_tdee_is_bmr_plus_neat(self) -> None:
|
||||
breakdown = compute_tdee(PROFILE, steps_total=0, workouts=[])
|
||||
bmr = bmr_mifflin(sex="male", weight_kg=86, height_cm=180, age=30)
|
||||
self.assertEqual(breakdown["bmr"], round(bmr, 0))
|
||||
self.assertEqual(breakdown["neat_kcal"], DEFAULT_NEAT_KCAL)
|
||||
self.assertEqual(breakdown["steps_kcal"], 0.0)
|
||||
self.assertEqual(breakdown["workout_kcal"], 0.0)
|
||||
self.assertEqual(breakdown["tdee"], round(bmr + DEFAULT_NEAT_KCAL, 0))
|
||||
|
||||
def test_steps_kcal_at_reference_weight(self) -> None:
|
||||
kcal = steps_kcal(steps=10000, weight_kg=86)
|
||||
self.assertAlmostEqual(kcal, 400.0, delta=1.0)
|
||||
|
||||
def test_daily_targets_include_activity(self) -> None:
|
||||
daily = compute_daily_targets(
|
||||
PROFILE,
|
||||
steps_total=8000,
|
||||
workouts=[{"active_calories": 450}],
|
||||
)
|
||||
self.assertGreater(daily["steps_kcal"], 0)
|
||||
self.assertEqual(daily["workout_kcal"], 450.0)
|
||||
self.assertEqual(
|
||||
daily["tdee"],
|
||||
daily["bmr"] + daily["neat_kcal"] + daily["steps_kcal"] + daily["workout_kcal"],
|
||||
)
|
||||
self.assertEqual(daily["calorie_target"], daily["tdee"])
|
||||
|
||||
def test_compute_targets_rest_day(self) -> None:
|
||||
targets = compute_targets(PROFILE)
|
||||
self.assertEqual(targets["steps_kcal"], 0)
|
||||
self.assertEqual(targets["workout_kcal"], 0)
|
||||
self.assertEqual(targets["calorie_target"], targets["tdee"])
|
||||
|
||||
def test_water_target(self) -> None:
|
||||
self.assertEqual(water_target_l(70), 2.3)
|
||||
|
||||
def test_workout_active_calories_priority(self) -> None:
|
||||
kcal = estimate_workout_active_kcal(
|
||||
{"active_calories": 300, "duration_min": 60, "met": 9.8},
|
||||
weight_kg=86,
|
||||
)
|
||||
self.assertEqual(kcal, 300.0)
|
||||
|
||||
def test_workout_met_fallback(self) -> None:
|
||||
kcal = estimate_workout_active_kcal(
|
||||
{"title": "бег", "duration_min": 60},
|
||||
weight_kg=86,
|
||||
)
|
||||
self.assertAlmostEqual(kcal, 9.8 * 86, delta=1.0)
|
||||
|
||||
def test_workout_no_data_returns_zero(self) -> None:
|
||||
self.assertEqual(estimate_workout_active_kcal({}, weight_kg=70), 0.0)
|
||||
|
||||
def test_infer_met_from_title(self) -> None:
|
||||
self.assertEqual(infer_met({"title": "пробежал триатлон"}), 10.0)
|
||||
|
||||
def test_workouts_kcal_total(self) -> None:
|
||||
total = workouts_kcal_total(
|
||||
[
|
||||
{"active_calories": 100},
|
||||
{"title": "ходьба", "duration_min": 30},
|
||||
],
|
||||
weight_kg=86,
|
||||
)
|
||||
self.assertGreater(total, 100)
|
||||
|
||||
|
||||
class MacroTargetsTests(unittest.TestCase):
|
||||
def test_lose_macros_from_weight(self) -> None:
|
||||
macros = macro_targets(2363, 86, "lose")
|
||||
self.assertEqual(macros["protein_g"], 189)
|
||||
self.assertEqual(macros["fat_g"], 86)
|
||||
self.assertEqual(macros["carbs_g"], 208)
|
||||
|
||||
def test_maintain_macros_from_weight(self) -> None:
|
||||
macros = macro_targets(2000, 86, "maintain")
|
||||
self.assertEqual(macros["protein_g"], 155)
|
||||
self.assertEqual(macros["fat_g"], 86)
|
||||
self.assertEqual(macros["carbs_g"], 152)
|
||||
|
||||
def test_active_day_increases_carbs_only(self) -> None:
|
||||
rest = compute_daily_targets(PROFILE, steps_total=0, workouts=[])
|
||||
active = compute_daily_targets(
|
||||
PROFILE,
|
||||
steps_total=8000,
|
||||
workouts=[{"active_calories": 450}],
|
||||
)
|
||||
self.assertEqual(rest["protein_g"], active["protein_g"])
|
||||
self.assertEqual(rest["fat_g"], active["fat_g"])
|
||||
self.assertGreater(active["calorie_target"], rest["calorie_target"])
|
||||
self.assertGreater(active["carbs_g"], rest["carbs_g"])
|
||||
|
||||
def test_low_calorie_target_floors_carbs_at_zero(self) -> None:
|
||||
macros = macro_targets(1000, 86, "lose")
|
||||
self.assertEqual(macros["protein_g"], 189)
|
||||
self.assertEqual(macros["fat_g"], 86)
|
||||
self.assertEqual(macros["carbs_g"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.vision.analyze import VisionService, format_user_message, format_vision_turn_hint
|
||||
from app.vision.preprocess import PreparedImage
|
||||
|
||||
|
||||
class VisionAnalyzeTests(unittest.TestCase):
|
||||
def test_format_user_message_with_fitness_hints(self) -> None:
|
||||
from app.vision.analyze import VisionResult
|
||||
|
||||
result = VisionResult(
|
||||
parsed={
|
||||
"description": "Экран тренировки бег",
|
||||
"document_type": "fitness_workout",
|
||||
"extracted_text": ["45 мин", "420 ккал"],
|
||||
"tables": [{"title": "Пульс", "rows": [["средний", "152"]]}],
|
||||
"fitness_hints": {"duration_min": 45, "active_calories": 420},
|
||||
"confidence": "high",
|
||||
},
|
||||
raw_content="{}",
|
||||
model="test-model",
|
||||
)
|
||||
text = format_user_message("запиши тренировку", result)
|
||||
self.assertIn("[Скриншот: fitness_workout", text)
|
||||
self.assertIn("420 ккал", text)
|
||||
self.assertIn("Подпись: запиши тренировку", text)
|
||||
|
||||
def test_run_async_analyze(self) -> None:
|
||||
import asyncio
|
||||
|
||||
prepared = PreparedImage(
|
||||
jpeg_bytes=b"fakejpeg",
|
||||
width=100,
|
||||
height=100,
|
||||
original_bytes=200,
|
||||
compressed_bytes=100,
|
||||
)
|
||||
payload = {
|
||||
"description": "Шаги за день",
|
||||
"document_type": "fitness_steps",
|
||||
"extracted_text": ["8432 шага"],
|
||||
"tables": [],
|
||||
"fitness_hints": {"steps": 8432},
|
||||
"confidence": "high",
|
||||
"notes": "",
|
||||
}
|
||||
|
||||
async def _run() -> None:
|
||||
service = VisionService()
|
||||
with patch.object(
|
||||
service.llm,
|
||||
"complete_vision",
|
||||
new=AsyncMock(return_value={"content": json.dumps(payload), "model": "vision-test", "usage": {}}),
|
||||
):
|
||||
result = await service.analyze_prepared(prepared, user_hint="шаги")
|
||||
self.assertEqual(result.parsed["document_type"], "fitness_steps")
|
||||
self.assertEqual(result.parsed["fitness_hints"]["steps"], 8432)
|
||||
self.assertEqual(result.model, "vision-test")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_format_vision_turn_hint(self) -> None:
|
||||
self.assertEqual(format_vision_turn_hint("привет"), "")
|
||||
self.assertIn("не видишь", format_vision_turn_hint("[Скриншот: other, confidence=high]\nОписание: test"))
|
||||
self.assertIn("не видишь", format_vision_turn_hint("[Скриншот 2/3: other, confidence=high]\nОписание: test"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
import unittest
|
||||
|
||||
from app.vision.analyze import VisionResult, format_user_message, format_user_messages, vision_debug_payloads
|
||||
|
||||
|
||||
class FormatUserMessageTests(unittest.TestCase):
|
||||
def test_parse_error_in_message(self) -> None:
|
||||
result = VisionResult(parsed={}, raw_content="not json", parse_error="bad json")
|
||||
text = format_user_message("", result)
|
||||
self.assertIn("parse_error", text)
|
||||
|
||||
def test_multiple_screenshots_numbered(self) -> None:
|
||||
results = [
|
||||
VisionResult(
|
||||
parsed={"document_type": "fitness_steps", "confidence": "high", "description": "Шаги"},
|
||||
raw_content="{}",
|
||||
),
|
||||
VisionResult(
|
||||
parsed={"document_type": "other", "confidence": "medium", "description": "Еда"},
|
||||
raw_content="{}",
|
||||
),
|
||||
]
|
||||
text = format_user_messages("сравни", results)
|
||||
self.assertIn("[Скриншот 1/2: fitness_steps", text)
|
||||
self.assertIn("[Скриншот 2/2: other", text)
|
||||
self.assertIn("Подпись: сравни", text)
|
||||
self.assertEqual(text.count("Подпись:"), 1)
|
||||
|
||||
def test_vision_debug_payloads_multi(self) -> None:
|
||||
results = [
|
||||
VisionResult(parsed={"document_type": "other"}, raw_content="{}", model="m1"),
|
||||
VisionResult(parsed={"document_type": "other"}, raw_content="{}", model="m1"),
|
||||
]
|
||||
payload = vision_debug_payloads(results)
|
||||
assert payload is not None
|
||||
self.assertEqual(payload["count"], 2)
|
||||
self.assertEqual(len(payload["images"]), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
import io
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.vision.preprocess import prepare_image
|
||||
|
||||
|
||||
class VisionPreprocessTests(unittest.TestCase):
|
||||
def _make_png(self, width: int, height: int) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (width, height), color=(120, 80, 200)).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
def test_resize_large_image(self) -> None:
|
||||
raw = self._make_png(2400, 1600)
|
||||
prepared = prepare_image(raw)
|
||||
self.assertLessEqual(max(prepared.width, prepared.height), 1280)
|
||||
self.assertLess(prepared.compressed_bytes, prepared.original_bytes)
|
||||
|
||||
def test_small_image_keeps_dimensions(self) -> None:
|
||||
raw = self._make_png(640, 480)
|
||||
prepared = prepare_image(raw)
|
||||
self.assertEqual(prepared.width, 640)
|
||||
self.assertEqual(prepared.height, 480)
|
||||
self.assertEqual(prepared.mime, "image/jpeg")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,19 @@
|
||||
import unittest
|
||||
|
||||
from app.vision.storage import format_upload_images_markdown, upload_media_path
|
||||
|
||||
|
||||
class UploadMarkdownTests(unittest.TestCase):
|
||||
def test_single_image(self) -> None:
|
||||
md = format_upload_images_markdown(5, ["abc.jpg"])
|
||||
self.assertIn(upload_media_path(5, "abc.jpg"), md)
|
||||
self.assertIn("![скриншот]", md)
|
||||
|
||||
def test_multiple_images(self) -> None:
|
||||
md = format_upload_images_markdown(3, ["a.jpg", "b.jpg"])
|
||||
self.assertIn("скриншот 1/2", md)
|
||||
self.assertIn("скриншот 2/2", md)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,6 +7,11 @@ def test_build_weather_dashboard_structure():
|
||||
fake_weather = {
|
||||
"ok": True,
|
||||
"location": "Test City",
|
||||
"data_source": "local",
|
||||
"local_field_coverage": {"current": ["temperature_2m"], "hourly": [], "daily": []},
|
||||
"field_coverage": {"current": ["temperature_2m"], "hourly": [], "daily": []},
|
||||
"sync_hint": "",
|
||||
"merged_fields": [],
|
||||
"current": {
|
||||
"time": "2026-06-13T12:00",
|
||||
"temperature_c": 18.5,
|
||||
@@ -27,12 +32,22 @@ def test_build_weather_dashboard_structure():
|
||||
"conditions": "переменная облачность",
|
||||
}
|
||||
],
|
||||
"daily": [
|
||||
{
|
||||
"date": "2026-06-14",
|
||||
"label": "Завтра",
|
||||
"temperature_max_c": 20.0,
|
||||
"temperature_min_c": 12.0,
|
||||
"conditions": "дождь",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with patch("app.homelab.openmeteo.OpenMeteoClient") as mock_cls:
|
||||
client = mock_cls.return_value
|
||||
client.fetch_current_and_hourly.return_value = fake_weather
|
||||
client.fetch_forecast.return_value = fake_weather
|
||||
client.rain_summary.return_value = "Существенных осадков в ближайшие часы не ожидается."
|
||||
client.daily_summary.return_value = "Завтра: 12–20°C"
|
||||
client.cache_status.return_value = {
|
||||
"has_data": True,
|
||||
"cached": True,
|
||||
@@ -40,18 +55,21 @@ def test_build_weather_dashboard_structure():
|
||||
"age_sec": 10,
|
||||
"ttl_sec": 300,
|
||||
"expires_in_sec": 290,
|
||||
"source": "local",
|
||||
"merged_fields": [],
|
||||
}
|
||||
client.location_name = "Test City"
|
||||
client.lat = 59.9
|
||||
client.lon = 30.3
|
||||
client.base_url = "http://openmeteo.test"
|
||||
client.cache_ttl = 300
|
||||
client.forecast_days = 7
|
||||
|
||||
result = build_weather_dashboard(hours_ahead=6)
|
||||
result = build_weather_dashboard(hours_ahead=6, days_ahead=7)
|
||||
|
||||
assert result["weather"]["ok"] is True
|
||||
assert "[Погода]" in result["assistant_context"]
|
||||
assert "None" not in result["assistant_context"]
|
||||
assert "temperature_2m" in result["available_fields"]["current"]
|
||||
assert "get_weather" in result["assistant_tools"]
|
||||
assert "daily" in result["available_fields"]
|
||||
assert result["daily_summary"]
|
||||
assert result["config"]["location"] == "Test City"
|
||||
|
||||
Reference in New Issue
Block a user