import re from datetime import date from pathlib import Path import pandas as pd import pytest from pdf_export import ( DAGOVERZICHT_MAX_DAYS, SECTION_AGP, SECTION_DAGOVERZICHT, SECTION_EVENTS, SECTION_INSULIN_PROFILE, SECTION_SUMMARY, _report_config, build_report_pdf, ) def _page_count(pdf_bytes: bytes) -> int: """Grof-maar-betrouwbaar paginatelling op de ruwe PDF-bytes: elke paginaobject bevat een '/Type /Page' (zonder de 's' van '/Type /Pages') in zijn objectdictionary, wat altijd onleesbaar-platte tekst blijft ook al staat contentstream-compressie aan — geen losse PDF-parser nodig voor deze structurele check (zelfde black-box-filosofie als de rest van deze testset: geen pixel-vergelijkingen, wel structuur).""" return len(re.findall(rb"/Type\s*/Page[^s]", pdf_bytes)) def _sample_glucose(): timestamps = pd.date_range("2026-01-01", periods=48, freq="30min") return pd.DataFrame({"timestamp": timestamps, "mmol": [6.0 + (i % 5) for i in range(48)]}) def _glucose_with_hypo_and_hyper_events(): """3 dagen data met een echte hypo- (<3.9, >=15 min) en hyperepisode (>10.0).""" timestamps = pd.date_range("2026-01-01", periods=3 * 24 * 12, freq="5min") values = [7.0] * len(timestamps) values[10:15] = [3.0] * 5 # 25 min hypo values[100:106] = [12.0] * 6 # 30 min hyper return pd.DataFrame({"timestamp": timestamps, "mmol": values}) def test_build_report_pdf_produces_valid_pdf_bytes(): glucose = _sample_glucose() events_summary = { "Hypo Level 1": {"count": 2, "avg_duration_minutes": 20.0, "events_per_week": 1.0}, } pdf_bytes = build_report_pdf( [SECTION_SUMMARY, SECTION_AGP, SECTION_EVENTS], glucose, events_summary, date(2026, 1, 1), date(2026, 1, 2), ) assert isinstance(pdf_bytes, bytes) assert pdf_bytes.startswith(b"%PDF") assert len(pdf_bytes) > 500 def test_build_report_pdf_handles_empty_glucose(): empty = pd.DataFrame(columns=["timestamp", "mmol"]) pdf_bytes = build_report_pdf( [SECTION_SUMMARY, SECTION_AGP], empty, {}, date(2026, 1, 1), date(2026, 1, 2) ) assert pdf_bytes.startswith(b"%PDF") def test_build_report_pdf_handles_no_sections_selected(): glucose = _sample_glucose() pdf_bytes = build_report_pdf([], glucose, {}, date(2026, 1, 1), date(2026, 1, 2)) assert pdf_bytes.startswith(b"%PDF") def test_build_report_pdf_includes_dagoverzicht_section(): glucose = _sample_glucose() pdf_bytes = build_report_pdf( [SECTION_DAGOVERZICHT], glucose, {}, date(2026, 1, 1), date(2026, 1, 2) ) assert pdf_bytes.startswith(b"%PDF") assert len(pdf_bytes) > 500 def test_build_report_pdf_caps_dagoverzicht_at_max_days(): timestamps = pd.date_range("2026-01-01", periods=(DAGOVERZICHT_MAX_DAYS + 10) * 24, freq="1h") glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)}) pdf_bytes = build_report_pdf( [SECTION_DAGOVERZICHT], glucose, {}, timestamps[0].date(), timestamps[-1].date(), ) assert pdf_bytes.startswith(b"%PDF") def test_build_report_pdf_draws_hypo_and_hyper_charts_when_events_present(): glucose = _glucose_with_hypo_and_hyper_events() events_summary = { "Hypo Level 1": {"count": 1, "avg_duration_minutes": 25.0, "events_per_week": 2.3}, "Hyper Level 1": {"count": 1, "avg_duration_minutes": 30.0, "events_per_week": 2.3}, } pdf_bytes = build_report_pdf( [SECTION_EVENTS], glucose, events_summary, date(2026, 1, 1), date(2026, 1, 4) ) assert pdf_bytes.startswith(b"%PDF") assert len(pdf_bytes) > 500 def test_build_report_pdf_always_has_at_least_a_cover_page(): empty = pd.DataFrame(columns=["timestamp", "mmol"]) pdf_bytes = build_report_pdf([], empty, {}, date(2026, 1, 1), date(2026, 1, 2)) assert _page_count(pdf_bytes) == 1 def test_build_report_pdf_one_page_per_single_page_section(): glucose = _sample_glucose() pdf_bytes = build_report_pdf( [SECTION_SUMMARY, SECTION_AGP], glucose, {}, date(2026, 1, 1), date(2026, 1, 2) ) # cover + summary + agp = 3 assert _page_count(pdf_bytes) == 3 def test_build_report_pdf_one_page_per_day_for_dagoverzicht(): timestamps = pd.date_range("2026-01-01", periods=3 * 24 * 12, freq="5min") # 3 dagen glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)}) pdf_bytes = build_report_pdf( [SECTION_DAGOVERZICHT], glucose, {}, date(2026, 1, 1), date(2026, 1, 3) ) # cover + 3 dagen assert _page_count(pdf_bytes) == 4 def test_build_report_pdf_caps_dagoverzicht_page_count_at_max_days(): timestamps = pd.date_range("2026-01-01", periods=(DAGOVERZICHT_MAX_DAYS + 10) * 24, freq="1h") glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)}) pdf_bytes = build_report_pdf( [SECTION_DAGOVERZICHT], glucose, {}, timestamps[0].date(), timestamps[-1].date() ) # cover + max. DAGOVERZICHT_MAX_DAYS dagpagina's, ondanks (DAGOVERZICHT_MAX_DAYS + 10) dagen aan data assert _page_count(pdf_bytes) == 1 + DAGOVERZICHT_MAX_DAYS def test_build_report_pdf_includes_insulin_profile_section_page(): glucose = _sample_glucose() profile = { "name": "Default", "dia": 5.0, "basal": [{"minutes": 0, "value": 0.8}, {"minutes": 720, "value": 1.1}], "sens": [{"minutes": 0, "value": 2.8}], "carbratio": [{"minutes": 0, "value": 10.0}], "target_low": [{"minutes": 0, "value": 4.5}], "target_high": [{"minutes": 0, "value": 8.0}], } pdf_bytes = build_report_pdf( [SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile ) # cover + insulineprofiel assert _page_count(pdf_bytes) == 2 assert pdf_bytes.startswith(b"%PDF") def test_build_report_pdf_insulin_profile_section_handles_missing_profile(): glucose = _sample_glucose() pdf_bytes = build_report_pdf( [SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=None ) assert _page_count(pdf_bytes) == 2 assert pdf_bytes.startswith(b"%PDF") def test_build_report_pdf_insulin_profile_section_renders_without_glucose(): """De Insulineprofiel-sectie gaat niet over glucosedata en moet dus ook renderen als er geen glucosemetingen zijn (in tegenstelling tot Samenvatting/AGP/Dagoverzicht, die wel glucosedata vereisen).""" empty = pd.DataFrame(columns=["timestamp", "mmol"]) profile = { "name": "Default", "dia": 5.0, "basal": [{"minutes": 0, "value": 0.8}], "sens": [{"minutes": 0, "value": 2.8}], "carbratio": [{"minutes": 0, "value": 10.0}], "target_low": [], "target_high": [], } pdf_bytes = build_report_pdf( [SECTION_INSULIN_PROFILE], empty, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile ) assert _page_count(pdf_bytes) == 2 def test_build_report_pdf_dagoverzicht_uses_treatments_and_profile_without_error(): """Regressietest: de dagpagina moet basaal/bolus/koolhydraten/streefwaarde- overlays tekenen zonder te crashen als er echte treatments/profile worden meegegeven (in plaats van de lege defaults).""" timestamps = pd.date_range("2026-01-01", periods=24 * 12, freq="5min") # 1 dag glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)}) treatments = pd.DataFrame( [ { "timestamp": pd.Timestamp("2026-01-01 08:00"), "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40.0, "type": "", "absolute": None, "duration": None, }, { "timestamp": pd.Timestamp("2026-01-01 12:00"), "eventType": "Correction Bolus", "insulin": 0.3, "carbs": None, "type": "SMB", "absolute": None, "duration": None, }, { "timestamp": pd.Timestamp("2026-01-01 06:00"), "eventType": "Temp Basal", "insulin": None, "carbs": None, "type": "", "absolute": 2.0, "duration": 60, }, ] ) profile = { "name": "Default", "dia": 5.0, "basal": [{"minutes": 0, "value": 0.8}], "sens": [{"minutes": 0, "value": 2.8}], "carbratio": [{"minutes": 0, "value": 10.0}], "target_low": [{"minutes": 0, "value": 4.5}], "target_high": [{"minutes": 0, "value": 8.0}], } pdf_bytes = build_report_pdf( [SECTION_DAGOVERZICHT], glucose, {}, date(2026, 1, 1), date(2026, 1, 1), treatments=treatments, profile=profile, ) assert pdf_bytes.startswith(b"%PDF") assert _page_count(pdf_bytes) == 2 # cover + 1 dag def test_report_config_uses_defaults_when_env_vars_unset(monkeypatch): for var in ("REPORT_INSULIN_TYPE", "REPORT_PUMP_TYPE", "REPORT_SENSOR_TYPE", "NIGHTSCOUT_URL"): monkeypatch.delenv(var, raising=False) config = _report_config() assert config["insulin_type"] == "Onbekend insulinetype" assert config["pump_type"] == "Onbekende pomp" assert config["sensor_type"] == "Onbekende sensor" assert config["nightscout_url"] == "" def test_report_config_reads_env_vars_when_set(monkeypatch): monkeypatch.setenv("REPORT_INSULIN_TYPE", "Lyumjev 100U") monkeypatch.setenv("REPORT_PUMP_TYPE", "Omnipod DASH") monkeypatch.setenv("REPORT_SENSOR_TYPE", "Dexcom G7") monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com/") config = _report_config() assert config["insulin_type"] == "Lyumjev 100U" assert config["pump_type"] == "Omnipod DASH" assert config["sensor_type"] == "Dexcom G7" assert config["nightscout_url"] == "https://example.com" # trailing slash gestript def test_icon_asset_exists(): icon_path = Path(__file__).parent.parent / "assets" / "icon.svg" assert icon_path.exists() assert icon_path.read_text(encoding="utf-8").strip().startswith("