Feature/treatment overlays #36

Merged
willemjan merged 3 commits from feature/treatment-overlays into feature/insulin-profile 2026-09-05 20:57:52 +00:00
10 changed files with 125 additions and 14 deletions
Showing only changes of commit a4d29e85a8 - Show all commits
+2
View File
@@ -5,4 +5,6 @@ __pycache__/
.git/ .git/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
.coverage
coverage.xml
tests/ tests/
+17 -3
View File
@@ -19,11 +19,21 @@ jobs:
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Lint (ruff) - name: Lint (ruff)
run: ruff check . run: ruff check .
- name: Unit tests (pytest) - name: Unit tests + coverage-gate (pytest)
run: pytest tests/ -v # fail_under (pyproject.toml, [tool.coverage.report]) laat deze stap
# falen als de dekking op de gemeten modules onder 80% zakt — app.py/
# pages/*.py zijn uitgesloten (zie pyproject.toml se coverage-omit),
# dat zijn dunne Streamlit-UI-scripts die niet zinvol te unit-testen
# zijn zonder een draaiende Streamlit-sessie.
run: pytest tests/ -v --cov=. --cov-report=term-missing --cov-report=xml
- name: Bewaar coverage-rapport
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# 2. SonarQube — codekwaliteit-gate # 2. SonarQube — codekwaliteit-gate (incl. coverage uit de vorige stap)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
sonarqube: sonarqube:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -32,6 +42,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 # SonarQube wil de volledige git-historie zien fetch-depth: 0 # SonarQube wil de volledige git-historie zien
- name: Download coverage-rapport
uses: actions/download-artifact@v4
with:
name: coverage-report
- name: SonarQube scan - name: SonarQube scan
uses: sonarsource/sonarqube-scan-action@v6 uses: sonarsource/sonarqube-scan-action@v6
env: env:
+2
View File
@@ -4,4 +4,6 @@ __pycache__/
.env .env
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
.coverage
coverage.xml
homelab-cicd-werkinstructie.md homelab-cicd-werkinstructie.md
+9
View File
@@ -41,3 +41,12 @@ klinische rapportagepagina's (zie `docs/requirements-reporting.md` en
pytest tests/ -v pytest tests/ -v
ruff check . ruff check .
``` ```
Coverage (zie `pyproject.toml` se `[tool.coverage.*]`): de CI-pipeline faalt
als de dekking op de gemeten modules onder 80% zakt (`app.py`/`pages/*.py`
zijn uitgesloten — dunne Streamlit-UI-scripts, niet zinvol te unit-testen
zonder een draaiende Streamlit-sessie).
```bash
pytest tests/ --cov=. --cov-report=term-missing
```
+6 -1
View File
@@ -17,13 +17,18 @@ import os
import streamlit as st import streamlit as st
def require_auth() -> None: def require_auth() -> None: # pragma: no cover
"""Blokkeer de pagina totdat het juiste wachtwoord is ingevoerd. """Blokkeer de pagina totdat het juiste wachtwoord is ingevoerd.
Slaat de status op in st.session_state zodat dit maar één keer per Slaat de status op in st.session_state zodat dit maar één keer per
browsersessie hoeft. Moet als eerste statement van elke pagina aangeroepen browsersessie hoeft. Moet als eerste statement van elke pagina aangeroepen
worden (app.py en elk bestand in pages/), omdat Streamlit iedere pagina worden (app.py en elk bestand in pages/), omdat Streamlit iedere pagina
als los script uitvoert. als los script uitvoert.
Pure Streamlit-widget-orkestratie (st.text_input/st.session_state/
st.rerun) — buiten scope van de unit-testdekking net als pages/*.py,
zie pyproject.toml se coverage-omit-lijst. De veiligheidsrelevante
logica zelf (verify_password()) is wel volledig getest.
""" """
if st.session_state.get("authenticated"): if st.session_state.get("authenticated"):
return return
+13
View File
@@ -1,2 +1,15 @@
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] pythonpath = ["."]
[tool.coverage.run]
# app.py en pages/*.py zijn dunne Streamlit-UI-scripts (net als de
# functies in ui_common.py/auth.py die zelf al met "# pragma: no cover"
# gemarkeerd zijn) — niet zinvol te unit-testen zonder een draaiende
# Streamlit-sessie, en buiten scope van de coverage-eis. De logica die ze
# aanroepen (reporting.py, nightscout_client.py, data_processing.py,
# pdf_export.py, profile_data.py, ui_common.py) wordt wél volledig gemeten.
omit = ["app.py", "pages/*", "tests/*", ".venv/*"]
[tool.coverage.report]
fail_under = 80
show_missing = true
+1
View File
@@ -6,4 +6,5 @@ python-dotenv>=1.0,<2.0
matplotlib>=3.9,<4.0 matplotlib>=3.9,<4.0
fpdf2>=2.7,<3.0 fpdf2>=2.7,<3.0
pytest>=8.0,<9.0 pytest>=8.0,<9.0
pytest-cov>=5.0,<6.0
ruff>=0.6,<1.0 ruff>=0.6,<1.0
+1
View File
@@ -4,3 +4,4 @@ sonar.sources=.
sonar.exclusions=data/**,tests/** sonar.exclusions=data/**,tests/**
sonar.tests=tests sonar.tests=tests
sonar.python.version=3.12 sonar.python.version=3.12
sonar.python.coverage.reportPaths=coverage.xml
+47
View File
@@ -1,5 +1,8 @@
from types import SimpleNamespace
import pandas as pd import pandas as pd
import plotly.graph_objects as go import plotly.graph_objects as go
import streamlit as st
from ui_common import ( from ui_common import (
PALETTE, PALETTE,
@@ -10,10 +13,26 @@ from ui_common import (
add_target_line_trace, add_target_line_trace,
add_threshold_lines, add_threshold_lines,
build_tir_stacked_bar_figure, build_tir_stacked_bar_figure,
get_colors,
hex_to_rgba, hex_to_rgba,
) )
def test_get_colors_returns_dark_palette_when_theme_is_dark(monkeypatch):
monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="dark")))
assert get_colors() == PALETTE["dark"]
def test_get_colors_returns_light_palette_when_theme_is_light(monkeypatch):
monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="light")))
assert get_colors() == PALETTE["light"]
def test_get_colors_falls_back_to_light_when_theme_context_unavailable(monkeypatch):
monkeypatch.setattr(st, "context", SimpleNamespace()) # geen .theme-attribuut
assert get_colors() == PALETTE["light"]
def test_hex_to_rgba_converts_correctly(): def test_hex_to_rgba_converts_correctly():
assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)" assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)"
@@ -55,12 +74,34 @@ def test_add_basal_area_trace_adds_one_filled_step_trace():
assert fig.data[0].line.shape == "hv" assert fig.data[0].line.shape == "hv"
def test_add_basal_area_trace_supports_secondary_y_on_a_subplot_figure():
from plotly.subplots import make_subplots
basal = pd.DataFrame(
{"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 06:00"]), "rate": [0.8, 1.2]}
)
fig = make_subplots(specs=[[{"secondary_y": True}]])
add_basal_area_trace(fig, basal, PALETTE["light"], row=1, secondary_y=True)
assert len(fig.data) == 1
assert fig.data[0].yaxis == "y2"
def test_add_basal_area_trace_skips_empty_input(): def test_add_basal_area_trace_skips_empty_input():
fig = go.Figure() fig = go.Figure()
add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"]) add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"])
assert len(fig.data) == 0 assert len(fig.data) == 0
def test_add_carb_bars_trace_supports_secondary_y_on_a_subplot_figure():
from plotly.subplots import make_subplots
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
fig = make_subplots(specs=[[{"secondary_y": True}]])
add_carb_bars_trace(fig, carbs, PALETTE["light"], row=1, secondary_y=True)
assert len(fig.data) == 1
assert fig.data[0].yaxis == "y2"
def test_add_carb_bars_trace_adds_one_bar_trace(): def test_add_carb_bars_trace_adds_one_bar_trace():
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]}) carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
fig = go.Figure() fig = go.Figure()
@@ -70,6 +111,12 @@ def test_add_carb_bars_trace_adds_one_bar_trace():
assert isinstance(fig.data[0], go.Bar) assert isinstance(fig.data[0], go.Bar)
def test_add_carb_bars_trace_skips_empty_input():
fig = go.Figure()
add_carb_bars_trace(fig, pd.DataFrame(columns=["timestamp", "carbs"]), PALETTE["light"])
assert len(fig.data) == 0
def test_add_bolus_marker_traces_adds_one_trace_per_category_present(): def test_add_bolus_marker_traces_adds_one_trace_per_category_present():
bolus = pd.DataFrame( bolus = pd.DataFrame(
{ {
+27 -10
View File
@@ -118,8 +118,12 @@ def get_colors() -> dict:
return PALETTE["dark" if theme_type == "dark" else "light"] return PALETTE["dark" if theme_type == "dark" else "light"]
def select_period_days(default_label: str) -> tuple[date, date, int]: def select_period_days(default_label: str) -> tuple[date, date, int]: # pragma: no cover
"""Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug.""" """Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug.
Pure Streamlit-widget-orkestratie (st.radio) — buiten scope van de
unit-testdekking net als pages/*.py, zie pyproject.toml se coverage-omit-lijst.
"""
labels = list(PERIOD_OPTIONS_DAYS.keys()) labels = list(PERIOD_OPTIONS_DAYS.keys())
label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True) label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True)
days = PERIOD_OPTIONS_DAYS[label] days = PERIOD_OPTIONS_DAYS[label]
@@ -129,11 +133,15 @@ def select_period_days(default_label: str) -> tuple[date, date, int]:
@st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...") @st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...")
def load_range_data(start: date, end: date): def load_range_data(start: date, end: date): # pragma: no cover
"""Gecachete Nightscout-fetch voor een vaste periode. """Gecachete Nightscout-fetch voor een vaste periode.
Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische
data van een afgeronde periode verandert niet. data van een afgeronde periode verandert niet.
Dunne st.cache_data-wrapper rond al-geteste fetch_entries_range()/
fetch_treatments_range() — buiten scope van de unit-testdekking net als
pages/*.py, zie pyproject.toml se coverage-omit-lijst.
""" """
glucose = fetch_entries_range(start, end) glucose = fetch_entries_range(start, end)
treatments = fetch_treatments_range(start, end) treatments = fetch_treatments_range(start, end)
@@ -141,10 +149,14 @@ def load_range_data(start: date, end: date):
@st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...") @st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...")
def load_profile() -> dict: def load_profile() -> dict: # pragma: no cover
"""Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v. """Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v.
1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/ 1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/
behandeldata van een lopende periode.""" behandeldata van een lopende periode.
Dunne st.cache_data-wrapper rond al-geteste fetch_profile()/parse_profile()
— buiten scope van de unit-testdekking net als pages/*.py.
"""
raw = fetch_profile() raw = fetch_profile()
return parse_profile(raw) return parse_profile(raw)
@@ -363,19 +375,21 @@ def add_target_line_trace(
) )
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: # pragma: no cover
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig. """Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.
Gebruikt door elke rapportagepagina, direct na de load_range_data-call. Gebruikt door elke rapportagepagina, direct na de load_range_data-call.
De beoordelingslogica zelf staat in reporting.py (pure functie) zodat het De beoordelingslogica zelf staat in reporting.py (pure functie, wél volledig
PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan hergebruiken. getest) zodat het PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan
hergebruiken. Deze functie is alleen de st.warning-weergave — buiten scope
van de unit-testdekking net als pages/*.py.
""" """
message = completeness_warning(glucose, start, end) message = completeness_warning(glucose, start, end)
if message: if message:
st.warning(message) st.warning(message)
def render_event_level( def render_event_level( # pragma: no cover
glucose: pd.DataFrame, glucose: pd.DataFrame,
threshold: float, threshold: float,
direction: str, direction: str,
@@ -386,7 +400,10 @@ def render_event_level(
"""Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau. """Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau.
Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die
elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen. elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen. Pure
Streamlit-widget-weergave rond al-geteste reporting.detect_events()/
summarize_events() — buiten scope van de unit-testdekking net als
pages/*.py.
""" """
events = detect_events(glucose, threshold, direction) events = detect_events(glucose, threshold, direction)
summary = summarize_events(events, period_days) summary = summarize_events(events, period_days)