Files
mop-skills/skills/datastore/tests/test_datastore.py
T

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"