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
willemjan/glucose-dashboard/pipeline/head This commit looks good
Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
@@ -5,4 +5,6 @@ __pycache__/
|
|||||||
.git/
|
.git/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
tests/
|
tests/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user