296 lines
10 KiB
Python
296 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
import datetime
|
|
import json
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
from datastore import DatastoreEngine
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_datastore():
|
|
"""Creates a temporary datastore directory with schemas for testing."""
|
|
temp_dir = Path(tempfile.mkdtemp())
|
|
data_dir = temp_dir / "data"
|
|
data_dir.mkdir(parents=True)
|
|
|
|
# Copy schemas from knowledge-wiki/data/_schema if available, or create them
|
|
schema_src = Path("/Users/moritz/src/infrapuzzle/knowledge-wiki/data/_schema")
|
|
if schema_src.is_dir():
|
|
shutil.copytree(schema_src, data_dir / "_schema")
|
|
|
|
engine = DatastoreEngine(data_dir=data_dir)
|
|
yield engine
|
|
shutil.rmtree(temp_dir)
|
|
|
|
|
|
def test_nutrition_explicit_logging(temp_datastore: DatastoreEngine):
|
|
"""Verifies logging food with explicit macro values and daily aggregation."""
|
|
res1 = temp_datastore.add_event(
|
|
kind="nutrition.intake",
|
|
subject="moritz",
|
|
actor="agent:koch",
|
|
start="2026-10-06T08:30:00+02:00",
|
|
meal="Frühstück",
|
|
payload={
|
|
"item": "Protein Porridge",
|
|
"qty": 300,
|
|
"unit": "g",
|
|
"protein_g": 35.5,
|
|
"carbs_g": 45.0,
|
|
"sugar_g": 5.0,
|
|
"fat_g": 8.0,
|
|
"fiber_g": 6.0,
|
|
"salt_g": 0.5,
|
|
"kcal": 420.0,
|
|
},
|
|
)
|
|
assert res1["status"] == "success"
|
|
assert res1["event"]["id"] is not None
|
|
|
|
res2 = temp_datastore.add_event(
|
|
kind="nutrition.intake",
|
|
subject="moritz",
|
|
actor="agent:koch",
|
|
start="2026-10-06T12:30:00+02:00",
|
|
meal="Mittagessen",
|
|
payload={
|
|
"item": "Hähnchen mit Reis",
|
|
"qty": 400,
|
|
"unit": "g",
|
|
"protein_g": 50.0,
|
|
"carbs_g": 60.0,
|
|
"sugar_g": 2.0,
|
|
"fat_g": 10.0,
|
|
"fiber_g": 4.0,
|
|
"salt_g": 1.2,
|
|
"kcal": 550.0,
|
|
},
|
|
)
|
|
assert res2["status"] == "success"
|
|
|
|
summary = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
|
|
nut = summary["nutrition"]
|
|
assert nut["item_count"] == 2
|
|
assert nut["total_protein_g"] == 85.5
|
|
assert nut["total_carbs_g"] == 105.0
|
|
assert nut["total_kcal"] == 970.0
|
|
|
|
|
|
def test_nutrition_from_food_reference(temp_datastore: DatastoreEngine):
|
|
"""Verifies that linking a food_id computes macros automatically from foods.per_100."""
|
|
temp_datastore.put_entity(
|
|
collection="foods",
|
|
record={
|
|
"id": "oatly-haferdrink",
|
|
"name": "Oatly Haferdrink",
|
|
"per_100": {
|
|
"kcal": 61.0,
|
|
"protein_g": 1.1,
|
|
"carbs_g": 7.1,
|
|
"sugar_g": 3.4,
|
|
"fat_g": 3.0,
|
|
"fiber_g": 0.8,
|
|
"salt_g": 0.1,
|
|
},
|
|
},
|
|
)
|
|
|
|
# Log 200 ml Oatly Haferdrink
|
|
res = temp_datastore.add_event(
|
|
kind="nutrition.intake",
|
|
subject="moritz",
|
|
actor="agent:koch",
|
|
start="2026-10-06T09:00:00+02:00",
|
|
payload={
|
|
"item": "Hafermilch im Kaffee",
|
|
"food_id": "oatly-haferdrink",
|
|
"qty": 200,
|
|
"unit": "ml",
|
|
},
|
|
)
|
|
assert res["status"] == "success"
|
|
|
|
# Query v_nutrition
|
|
rows = temp_datastore.query("SELECT protein_g, carbs_g, kcal FROM v_nutrition WHERE food_id = 'oatly-haferdrink'")
|
|
assert len(rows) == 1
|
|
assert round(rows[0]["protein_g"], 2) == 2.2
|
|
assert round(rows[0]["carbs_g"], 2) == 14.2
|
|
assert round(rows[0]["kcal"], 0) == 122.0
|
|
|
|
|
|
def test_baby_sleep_open_and_close(temp_datastore: DatastoreEngine):
|
|
"""Verifies open-ended interval sleep logging and stop_event duration calculations."""
|
|
start_time = "2026-10-06T13:00:00+02:00"
|
|
end_time = "2026-10-06T14:30:00+02:00"
|
|
|
|
# 1. Start open sleep
|
|
res_start = temp_datastore.add_event(
|
|
kind="sleep.session",
|
|
subject="mila",
|
|
actor="agent:hebamme",
|
|
start=start_time,
|
|
payload={"type": "nap", "location": "bett"},
|
|
)
|
|
assert res_start["status"] == "success"
|
|
assert res_start["event"]["end"] is None
|
|
|
|
# Verify open status
|
|
summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-06")
|
|
assert "open_sleep" in summary
|
|
assert summary["open_sleep"]["sleep_type"] == "nap"
|
|
|
|
# 2. Prevent overlapping second open sleep
|
|
with pytest.raises(ValueError, match="already has an open sleep session"):
|
|
temp_datastore.add_event(
|
|
kind="sleep.session",
|
|
subject="mila",
|
|
actor="agent:hebamme",
|
|
start="2026-10-06T13:30:00+02:00",
|
|
payload={"type": "nap"},
|
|
)
|
|
|
|
# 3. Stop sleep
|
|
res_stop = temp_datastore.stop_event(
|
|
kind="sleep.session",
|
|
subject="mila",
|
|
end_time=end_time,
|
|
)
|
|
assert res_stop["status"] == "success"
|
|
assert res_stop["duration_minutes"] == 90
|
|
assert res_stop["duration_formatted"] == "1 h 30 min"
|
|
|
|
# 4. Verify aggregated summary
|
|
summary2 = temp_datastore.get_summary(subject="mila", date_str="2026-10-06")
|
|
slp = summary2["sleep"]
|
|
assert slp["nap_count"] == 1
|
|
assert slp["nap_sleep_minutes"] == 90
|
|
assert slp["total_sleep_hours"] == 1.5
|
|
|
|
|
|
def test_soft_delete_event(temp_datastore: DatastoreEngine):
|
|
"""Verifies that soft-deleted events are excluded from views."""
|
|
res = temp_datastore.add_event(
|
|
kind="nutrition.intake",
|
|
subject="moritz",
|
|
actor="agent:koch",
|
|
start="2026-10-06T07:00:00+02:00",
|
|
payload={"item": "Snack", "qty": 50, "unit": "g", "protein_g": 5.0, "kcal": 100.0},
|
|
)
|
|
event_id = res["event"]["id"]
|
|
|
|
# Verify present in summary
|
|
sum1 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
|
|
assert sum1["nutrition"]["total_kcal"] == 100.0
|
|
|
|
# Delete
|
|
del_res = temp_datastore.delete_event(event_id)
|
|
assert del_res["status"] == "success"
|
|
|
|
# Verify removed from view
|
|
sum2 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
|
|
assert sum2["nutrition"]["total_kcal"] == 0.0
|
|
|
|
|
|
def test_places_spatial_radius_search_both_engines(temp_datastore: DatastoreEngine):
|
|
"""Verifies distance calculation using both DuckDB spatial extension and pure-SQL fallback macro."""
|
|
temp_datastore.put_entity(
|
|
collection="places",
|
|
record={
|
|
"id": "spitalgarten-regensburg",
|
|
"name": "Spitalgarten Regensburg",
|
|
"category": "biergarten",
|
|
"lat": 49.0223,
|
|
"lon": 12.0978,
|
|
"address": "St.-Katharinen-Platz 1, 93059 Regensburg",
|
|
"google_maps_url": "https://maps.app.goo.gl/spitalgarten",
|
|
"tags": ["biergarten", "spielplatz"],
|
|
},
|
|
)
|
|
temp_datastore.put_entity(
|
|
collection="places",
|
|
record={
|
|
"id": "walhalla-donaustauf",
|
|
"name": "Walhalla",
|
|
"category": "sightseeing",
|
|
"lat": 49.0315,
|
|
"lon": 12.2245,
|
|
"address": "Walhallastraße 48, 93093 Donaustauf",
|
|
"google_maps_url": "https://maps.app.goo.gl/walhalla",
|
|
"tags": ["ausflug", "kultur"],
|
|
},
|
|
)
|
|
|
|
# 1. Test with spatial extension enabled
|
|
places_1k = temp_datastore.find_places_near(lat=49.0205, lon=12.0975, radius_m=1000.0)
|
|
assert len(places_1k) == 1
|
|
assert places_1k[0]["id"] == "spitalgarten-regensburg"
|
|
assert 150.0 < places_1k[0]["distance_meters"] < 300.0
|
|
|
|
# 2. Test fallback macro by forcing has_spatial_extension = False
|
|
temp_datastore.has_spatial_extension = False
|
|
places_fallback_1k = temp_datastore.find_places_near(lat=49.0205, lon=12.0975, radius_m=1000.0)
|
|
assert len(places_fallback_1k) == 1
|
|
assert places_fallback_1k[0]["id"] == "spitalgarten-regensburg"
|
|
assert 150.0 < places_fallback_1k[0]["distance_meters"] < 300.0
|
|
# Differences between Spheroid (WGS84 ellipsoid) and Haversine (sphere) are < 3m
|
|
assert abs(places_1k[0]["distance_meters"] - places_fallback_1k[0]["distance_meters"]) < 5.0
|
|
|
|
|
|
def test_garmin_daily_and_daily_balance(temp_datastore: DatastoreEngine):
|
|
"""Verifies that Garmin daily summaries join with nutrition to calculate caloric deficit/surplus deterministically."""
|
|
# 1. Log nutrition intake on 2026-10-01
|
|
temp_datastore.add_event(
|
|
kind="nutrition.intake",
|
|
subject="moritz",
|
|
actor="agent:koch",
|
|
start="2026-10-01T12:30:00+02:00",
|
|
payload={"item": "Mittagessen", "qty": 400, "unit": "g", "protein_g": 60.0, "kcal": 800.0},
|
|
)
|
|
temp_datastore.add_event(
|
|
kind="nutrition.intake",
|
|
subject="moritz",
|
|
actor="agent:koch",
|
|
start="2026-10-01T19:30:00+02:00",
|
|
payload={"item": "Abendessen", "qty": 500, "unit": "g", "protein_g": 80.0, "kcal": 1346.0},
|
|
)
|
|
|
|
# 2. Add Garmin daily summary for 2026-10-01
|
|
garmin_dir = temp_datastore.data_dir / "garmin" / "daily"
|
|
garmin_dir.mkdir(parents=True, exist_ok=True)
|
|
garmin_file = garmin_dir / "2026-10.jsonl"
|
|
with open(garmin_file, "a", encoding="utf-8") as f:
|
|
f.write(
|
|
json.dumps(
|
|
{
|
|
"calendarDate": "2026-10-01",
|
|
"totalKilocalories": 2890,
|
|
"activeKilocalories": 650,
|
|
"bmrKilocalories": 2240,
|
|
"totalSteps": 10500,
|
|
"restingHeartRate": 51,
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
# 3. Reload data into datastore
|
|
temp_datastore._load_all_data()
|
|
|
|
# 4. Query v_daily_balance
|
|
res = temp_datastore.query("SELECT * FROM v_daily_balance WHERE log_date = '2026-10-01'")
|
|
assert len(res) == 1
|
|
row = res[0]
|
|
assert row["subject"] == "moritz"
|
|
assert row["intake_kcal"] == 2146.0 # 800 + 1346
|
|
assert row["protein_g"] == 140.0 # 60 + 80
|
|
assert row["expenditure_kcal"] == 2890
|
|
assert row["active_kcal"] == 650
|
|
assert row["rmr_kcal"] == 2240
|
|
assert row["steps"] == 10500
|
|
assert row["balance_kcal"] == -744.0 # 2146 - 2890
|
|
assert row["balance_status"] == "Defizit"
|
|
|