Author SHA1 Message Date
willemjan de0ecebce5 Merge pull request 'Feature/treatment overlays' (#36) from feature/treatment-overlays into feature/insulin-profile
willemjan/glucose-dashboard/pipeline/head This commit looks good
Reviewed-on: #36
2026-09-05 20:57:52 +00:00
willemjan caf28046cb Merge pull request 'Feature/pdf report redesign' (#35) from feature/pdf-report-redesign into feature/treatment-overlays
willemjan/glucose-dashboard/pipeline/head This commit looks good
Reviewed-on: #35
2026-09-05 20:56:48 +00:00
willemjan a4d29e85a8 Merge pull request 'Add CI test-coverage gate at 80%' (#34) from feature/ci-coverage-gate into feature/pdf-report-redesign
willemjan/glucose-dashboard/pipeline/head This commit looks good
Reviewed-on: #34
2026-09-05 20:56:20 +00:00
willemjan 7f4377329a Add CI test-coverage gate at 80%, backfill remaining logic-module gaps
willemjan/glucose-dashboard/pipeline/head This commit looks good
pytest-cov + pyproject.toml [tool.coverage.*] measure reporting.py,
nightscout_client.py, data_processing.py, pdf_export.py, profile_data.py,
ui_common.py, and auth.py; app.py and pages/*.py are omitted (thin
Streamlit UI scripts, not meaningfully unit-testable without a running
Streamlit session — the handful of similarly UI-coupled functions left
in ui_common.py/auth.py are marked "# pragma: no cover" with the same
rationale, since the logic they call into is already fully tested).

The Gitea Actions test job now runs with --cov and fails the build
below 80%; the coverage XML is uploaded as a build artifact and fed
into the SonarQube scan (closing the gap flagged in
homelab-cicd-werkinstructie.md). Backfilled a few missing branches
(get_colors() theme fallback, secondary-y-axis overlay traces) to reach
98% on the measured modules — comfortable headroom above the 80% gate.

Builds on #29/#30/#31.
2026-09-05 22:52:29 +02:00
10 changed files with 125 additions and 14 deletions
+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)